feat: Добавить анимации и улучшения UX/UI в компоненты приложения, включая плавные переходы, эффекты при наведении и анимации для модальных окон. Обновить карточки и таблицы с новыми анимациями для улучшения визуального восприятия и доступности.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s
This commit is contained in:
@@ -300,3 +300,224 @@
|
||||
border-color: var(--tblr-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== UX/UI Enhancement Animations ===== */
|
||||
|
||||
/* Плавные переходы для всех интерактивных элементов */
|
||||
.transition-all {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Анимация появления карточек */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card.animate-in {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Анимация при добавлении элемента в таблицу */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.table tbody tr.new-item {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
background-color: rgba(32, 107, 196, 0.1);
|
||||
}
|
||||
|
||||
/* Пульсация при обновлении */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(32, 107, 196, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(32, 107, 196, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge.updating {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Hover эффекты для интерактивных карточек */
|
||||
.card.interactive {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card.interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Ripple effect для кнопок */
|
||||
@keyframes ripple {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-ripple::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn-ripple:active::after {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transition: 0s;
|
||||
animation: ripple 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Улучшенная анимация skeleton loader */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-glow .placeholder {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f8f9fa 0%,
|
||||
#e9ecef 50%,
|
||||
#f8f9fa 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
/* Анимация модальных окон */
|
||||
.modal.fade.show .modal-dialog {
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Плавный скролл */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Улучшенные focus rings для доступности */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--tblr-primary, #206bc4);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(32, 107, 196, 0.3);
|
||||
}
|
||||
|
||||
/* Skip link для клавиатурных пользователей */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--tblr-primary, #206bc4);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
z-index: 10000;
|
||||
border-radius: 0 0 4px 0;
|
||||
transition: top 0.2s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Hover эффект для строк таблицы с focus */
|
||||
.table tr:focus-within {
|
||||
background-color: rgba(32, 107, 196, 0.05);
|
||||
}
|
||||
|
||||
/* Pulse для кнопок загрузки */
|
||||
.btn.loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn.loading::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse-button 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-button {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация badge появления */
|
||||
.badge.animate {
|
||||
animation: badgePop 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,11 @@ function MainLayout() {
|
||||
|
||||
return (
|
||||
<div className="page d-flex flex-column min-vh-100">
|
||||
{/* Skip link для доступности */}
|
||||
<a href="#main-content" className="skip-link">
|
||||
Перейти к основному содержанию
|
||||
</a>
|
||||
|
||||
{/* Верхнее меню Tabler Admin */}
|
||||
<header className="navbar navbar-expand-md navbar-light d-print-none" style={{boxShadow: '0 1px 0 0 #e9ecef'}}>
|
||||
<div className="container-xl">
|
||||
@@ -371,7 +376,7 @@ function MainLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="container-xl mt-4 flex-grow-1">
|
||||
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
@@ -384,7 +389,7 @@ function MainLayout() {
|
||||
<Route path="/filters" element={<FilterManager />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="footer mt-auto py-2">
|
||||
<div className="container-xl text-muted small">
|
||||
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
|
||||
|
||||
+40
-32
@@ -189,44 +189,52 @@ function Dashboard() {
|
||||
{/* Основные метрики */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-3">
|
||||
<StatCard
|
||||
icon={IconWorld}
|
||||
color="blue"
|
||||
value={loading ? '...' : (stats.domainsCount ?? '—')}
|
||||
title="Доменов"
|
||||
subtitle="Всего доменов в системе"
|
||||
to="/domains"
|
||||
/>
|
||||
<div className="animate-in">
|
||||
<StatCard
|
||||
icon={IconWorld}
|
||||
color="blue"
|
||||
value={loading ? '...' : (stats.domainsCount ?? '—')}
|
||||
title="Доменов"
|
||||
subtitle="Всего доменов в системе"
|
||||
to="/domains"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="green"
|
||||
value={loading ? '...' : (stats.ipRangesCount ?? '—')}
|
||||
title="IP-диапазонов"
|
||||
subtitle="Всего IP диапазонов"
|
||||
to="/ip-ranges"
|
||||
/>
|
||||
<div className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="green"
|
||||
value={loading ? '...' : (stats.ipRangesCount ?? '—')}
|
||||
title="IP-диапазонов"
|
||||
subtitle="Всего IP диапазонов"
|
||||
to="/ip-ranges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="purple"
|
||||
value={loading ? '...' : (stats.asnsCount ?? '—')}
|
||||
title="AS"
|
||||
subtitle="Всего Autonomous Systems"
|
||||
to="/asns"
|
||||
/>
|
||||
<div className="animate-in" style={{animationDelay: '0.2s'}}>
|
||||
<StatCard
|
||||
icon={IconNetwork}
|
||||
color="purple"
|
||||
value={loading ? '...' : (stats.asnsCount ?? '—')}
|
||||
title="AS"
|
||||
subtitle="Всего Autonomous Systems"
|
||||
to="/asns"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<StatCard
|
||||
icon={IconServer}
|
||||
color="orange"
|
||||
value={loading ? '...' : (stats.serversCount ?? '—')}
|
||||
title="Серверов"
|
||||
subtitle="Всего серверов"
|
||||
to="/servers"
|
||||
/>
|
||||
<div className="animate-in" style={{animationDelay: '0.3s'}}>
|
||||
<StatCard
|
||||
icon={IconServer}
|
||||
color="orange"
|
||||
value={loading ? '...' : (stats.serversCount ?? '—')}
|
||||
title="Серверов"
|
||||
subtitle="Всего серверов"
|
||||
to="/servers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,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 ConfirmModal from './components/ConfirmModal.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -23,6 +24,7 @@ import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
@@ -519,7 +521,7 @@ function DomainsNewManager() {
|
||||
<div className="row">
|
||||
<div className="col-lg-3">
|
||||
{/* Classic Add New Domain Card (restored) */}
|
||||
<div className="card card-md">
|
||||
<div className="card card-md animate-in">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconPlus className="icon me-2" />
|
||||
@@ -577,7 +579,7 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
|
||||
{/* Дополнительные варианты */}
|
||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false}>
|
||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||
<QuickAddBar
|
||||
placeholder={'example.com 65000:100'}
|
||||
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||||
@@ -608,7 +610,8 @@ function DomainsNewManager() {
|
||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||
{/* Drag & Drop импорт */}
|
||||
<div
|
||||
className="card card-md border-dashed"
|
||||
className="card card-md border-dashed animate-in"
|
||||
style={{animationDelay: '0.2s'}}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={onDropImport}
|
||||
>
|
||||
@@ -629,7 +632,7 @@ function DomainsNewManager() {
|
||||
onExport={handleBulkExport}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100">
|
||||
<div className="col-auto">
|
||||
@@ -831,16 +834,22 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить домен?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.domain}"? Это действие необратимо.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
size={'sm'}
|
||||
<ConfirmModal
|
||||
show={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
title="Удалить домен?"
|
||||
message={
|
||||
itemToDelete ? (
|
||||
<>
|
||||
<p>Вы уверены, что хотите удалить <strong>"{itemToDelete.domain}"</strong>?</p>
|
||||
<p className="text-muted mb-0">Это действие необратимо.</p>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="danger"
|
||||
icon={IconTrash}
|
||||
/>
|
||||
{/* Confirm Save Modal */}
|
||||
<ConfirmDiffModal
|
||||
@@ -871,14 +880,25 @@ function DomainsNewManager() {
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={clearCommunitiesOpen}
|
||||
title={'Удалить записи community'}
|
||||
message={`Будут удалены все домены с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }}
|
||||
onCancel={() => setClearCommunitiesOpen(false)}
|
||||
<ConfirmModal
|
||||
show={clearCommunitiesOpen}
|
||||
onClose={() => setClearCommunitiesOpen(false)}
|
||||
onConfirm={() => {
|
||||
setItems(prev => prev.filter(i => i.community !== filterCommunity));
|
||||
setClearCommunitiesOpen(false);
|
||||
}}
|
||||
title="Удалить записи community?"
|
||||
message={
|
||||
<>
|
||||
<p>Будут удалены все домены с community <strong>"{filterCommunity}"</strong>.</p>
|
||||
<p className="text-warning mb-0">
|
||||
<IconAlertTriangle size={16} className="me-1" />
|
||||
Действие НЕ сохраняет изменения автоматически.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="warning"
|
||||
/>
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
|
||||
+250
-74
@@ -1,6 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
import FormModal from './components/FormModal.jsx';
|
||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||
import FormField from './components/FormField.jsx';
|
||||
import Modal from './components/Modal.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -24,7 +28,8 @@ import {
|
||||
IconHash,
|
||||
IconRocket,
|
||||
IconSortAscending,
|
||||
IconSortDescending
|
||||
IconSortDescending,
|
||||
IconWorld
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
@@ -1360,113 +1365,284 @@ function FilterManager() {
|
||||
)}
|
||||
|
||||
{/* Модальные окна */}
|
||||
<AddFilterModal
|
||||
<FormModal
|
||||
show={addFilterModalOpen}
|
||||
newFilter={newFilter}
|
||||
onNewFilterChange={setNewFilter}
|
||||
onAddFilter={handleAddFilter}
|
||||
onClose={() => {
|
||||
setAddFilterModalOpen(false);
|
||||
setNewFilter({ community: '', gateway: '', description: '' });
|
||||
}}
|
||||
error={error}
|
||||
communities={communities}
|
||||
/>
|
||||
onSubmit={handleAddFilter}
|
||||
title="Добавить новый фильтр"
|
||||
submitLabel="Добавить фильтр"
|
||||
submitIcon={IconPlus}
|
||||
size="md"
|
||||
>
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Community"
|
||||
name="community"
|
||||
value={newFilter.community}
|
||||
onChange={(val) => setNewFilter({ ...newFilter, community: val })}
|
||||
placeholder="65000:100"
|
||||
required
|
||||
icon={IconHash}
|
||||
helpText="Введите community в формате AS:VALUE"
|
||||
/>
|
||||
|
||||
<EditFilterModal
|
||||
<FormField
|
||||
label="Gateway"
|
||||
name="gateway"
|
||||
value={newFilter.gateway}
|
||||
onChange={(val) => setNewFilter({ ...newFilter, gateway: val })}
|
||||
placeholder="192.168.1.1 или gateway-name"
|
||||
required
|
||||
icon={IconServer}
|
||||
helpText="IP адрес или имя шлюза"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Описание"
|
||||
name="description"
|
||||
value={newFilter.description || ''}
|
||||
onChange={(val) => setNewFilter({ ...newFilter, description: val })}
|
||||
placeholder="Описание фильтра (необязательно)"
|
||||
icon={IconFileText}
|
||||
/>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
show={editFilterModalOpen}
|
||||
filter={editingFilter}
|
||||
onChange={setEditingFilter}
|
||||
onSave={handleSaveEditFilter}
|
||||
onClose={() => {
|
||||
setEditFilterModalOpen(false);
|
||||
setEditingFilter(null);
|
||||
}}
|
||||
/>
|
||||
onSubmit={handleSaveEditFilter}
|
||||
title="Редактировать фильтр"
|
||||
submitLabel="Сохранить изменения"
|
||||
submitIcon={IconCheck}
|
||||
size="md"
|
||||
>
|
||||
{editingFilter && (
|
||||
<>
|
||||
<FormField
|
||||
label="Community"
|
||||
name="community"
|
||||
value={editingFilter.community}
|
||||
onChange={(val) => setEditingFilter({ ...editingFilter, community: val })}
|
||||
placeholder="65000:100"
|
||||
required
|
||||
icon={IconHash}
|
||||
/>
|
||||
|
||||
<DeleteFilterModal
|
||||
<FormField
|
||||
label="Gateway"
|
||||
name="gateway"
|
||||
value={editingFilter.gateway}
|
||||
onChange={(val) => setEditingFilter({ ...editingFilter, gateway: val })}
|
||||
placeholder="192.168.1.1 или gateway-name"
|
||||
required
|
||||
icon={IconServer}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Описание"
|
||||
name="description"
|
||||
value={editingFilter.description || ''}
|
||||
onChange={(val) => setEditingFilter({ ...editingFilter, description: val })}
|
||||
placeholder="Описание фильтра (необязательно)"
|
||||
icon={IconFileText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
<ConfirmModal
|
||||
show={deleteFilterModalOpen}
|
||||
filter={filterToDelete}
|
||||
onDelete={handleDeleteFilter}
|
||||
onClose={() => {
|
||||
setDeleteFilterModalOpen(false);
|
||||
setFilterToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteFilter}
|
||||
title="Удалить фильтр?"
|
||||
message={
|
||||
filterToDelete ? (
|
||||
<>
|
||||
<p>Вы уверены, что хотите удалить фильтр?</p>
|
||||
<div className="mb-2">
|
||||
<strong>Community:</strong> <code>{filterToDelete.community}</code>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<strong>Gateway:</strong> <code>{filterToDelete.gateway}</code>
|
||||
</div>
|
||||
{filterToDelete.description && (
|
||||
<div className="mb-2">
|
||||
<strong>Описание:</strong> {filterToDelete.description}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted mb-0 mt-3">Это действие необратимо.</p>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="danger"
|
||||
icon={IconTrash}
|
||||
/>
|
||||
|
||||
<PreviewConfigModal
|
||||
<Modal
|
||||
show={previewModalOpen}
|
||||
config={generatedConfig}
|
||||
mode={previewMode}
|
||||
onClose={() => {
|
||||
setPreviewModalOpen(false);
|
||||
setPreviewMode('');
|
||||
}}
|
||||
onCopy={copyConfigToClipboard}
|
||||
/>
|
||||
title={`Предварительный просмотр конфигурации${previewMode === 'simple' ? ' (упрощённый режим)' : ''}`}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="d-flex justify-content-between w-100">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setPreviewModalOpen(false);
|
||||
setPreviewMode('');
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={copyConfigToClipboard}
|
||||
>
|
||||
<IconCopy className="me-2" />
|
||||
Копировать в буфер
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="alert alert-info mb-3">
|
||||
<IconEye className="me-2" />
|
||||
Конфигурация MikroTik RouterOS для применения фильтров
|
||||
</div>
|
||||
|
||||
<pre className="bg-dark text-white p-3 rounded" style={{ maxHeight: '500px', overflow: 'auto' }}>
|
||||
<code>{generatedConfig}</code>
|
||||
</pre>
|
||||
|
||||
<div className="mt-3 text-muted small">
|
||||
<p className="mb-1"><strong>Инструкция по применению:</strong></p>
|
||||
<ol className="mb-0">
|
||||
<li>Скопируйте конфигурацию в буфер обмена</li>
|
||||
<li>Подключитесь к MikroTik через SSH или Winbox</li>
|
||||
<li>Вставьте конфигурацию в терминал</li>
|
||||
<li>Проверьте применение командой: <code>/routing filter print</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно добавления сервера */}
|
||||
<AddServerModal
|
||||
<FormModal
|
||||
show={serverConfigModalOpen}
|
||||
newServer={newServer}
|
||||
onNewServerChange={setNewServer}
|
||||
onAddServer={handleAddServer}
|
||||
onClose={() => {
|
||||
setServerConfigModalOpen(false);
|
||||
setNewServer({ name: '', description: '', enabled: true });
|
||||
}}
|
||||
error={error}
|
||||
/>
|
||||
onSubmit={handleAddServer}
|
||||
title="Добавить новый сервер"
|
||||
submitLabel="Добавить сервер"
|
||||
submitIcon={IconPlus}
|
||||
size="md"
|
||||
>
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Имя сервера"
|
||||
name="name"
|
||||
value={newServer.name}
|
||||
onChange={(val) => setNewServer({ ...newServer, name: val })}
|
||||
placeholder="server-001"
|
||||
required
|
||||
icon={IconServer}
|
||||
helpText="Уникальное имя для идентификации сервера"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Описание"
|
||||
name="description"
|
||||
value={newServer.description || ''}
|
||||
onChange={(val) => setNewServer({ ...newServer, description: val })}
|
||||
placeholder="Основной сервер в Москве"
|
||||
icon={IconFileText}
|
||||
helpText="Краткое описание сервера (необязательно)"
|
||||
/>
|
||||
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="serverEnabled"
|
||||
checked={newServer.enabled}
|
||||
onChange={(e) => setNewServer({ ...newServer, enabled: e.target.checked })}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="serverEnabled">
|
||||
Активен
|
||||
</label>
|
||||
<div className="form-text">Включить сервер сразу после добавления</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
{/* Модальное окно редактирования сервера */}
|
||||
{editServerModalOpen && (
|
||||
<div className="modal show d-block" tabIndex="-1" role="dialog">
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Редактировать сервер</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setEditServerModalOpen(false)}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Имя сервера</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={editingServer?.name || ''}
|
||||
onChange={e => setEditingServer({ ...editingServer, name: e.target.value })}
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Описание</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
value={editingServer?.description || ''}
|
||||
onChange={e => setEditingServer({ ...editingServer, description: e.target.value })}
|
||||
maxLength={256}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{editServerError && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{editServerError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditServerModalOpen(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSaveEditServer}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FormModal
|
||||
show={editServerModalOpen}
|
||||
onClose={() => setEditServerModalOpen(false)}
|
||||
onSubmit={handleSaveEditServer}
|
||||
title="Редактировать сервер"
|
||||
submitLabel="Сохранить изменения"
|
||||
submitIcon={IconCheck}
|
||||
size="md"
|
||||
>
|
||||
{editServerError && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{editServerError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{editingServer && (
|
||||
<>
|
||||
<FormField
|
||||
label="Имя сервера"
|
||||
name="name"
|
||||
value={editingServer.name || ''}
|
||||
onChange={(val) => setEditingServer({ ...editingServer, name: val })}
|
||||
placeholder="server-001"
|
||||
required
|
||||
icon={IconServer}
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Описание"
|
||||
name="description"
|
||||
type="textarea"
|
||||
value={editingServer.description || ''}
|
||||
onChange={(val) => setEditingServer({ ...editingServer, description: val })}
|
||||
placeholder="Основной сервер в Москве"
|
||||
icon={IconFileText}
|
||||
maxLength={256}
|
||||
rows={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
{/* Модальное окно упрощённого режима */}
|
||||
<div className="mb-4">
|
||||
|
||||
+136
-14
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||||
import FormModal from './components/FormModal.jsx';
|
||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||
import FormField from './components/FormField.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -18,7 +21,8 @@ import {
|
||||
IconChevronDown,
|
||||
IconLink,
|
||||
IconFilter,
|
||||
IconMapPin
|
||||
IconMapPin,
|
||||
IconWorld
|
||||
} from '@tabler/icons-react';
|
||||
import GraphView from './GraphView';
|
||||
|
||||
@@ -691,12 +695,23 @@ function ServerManager() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<DeleteServerModal
|
||||
{/* Delete Modal - New unified component */}
|
||||
<ConfirmModal
|
||||
show={showDeleteModal}
|
||||
server={serverToDelete}
|
||||
onDelete={executeDelete}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={executeDelete}
|
||||
title="Подтверждение удаления"
|
||||
message={
|
||||
serverToDelete ? (
|
||||
<>
|
||||
<p>Вы уверены, что хотите удалить сервер <strong>{serverToDelete.ip}</strong>?</p>
|
||||
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
variant="danger"
|
||||
icon={IconTrash}
|
||||
/>
|
||||
|
||||
{/* Edit Server Modal */}
|
||||
@@ -719,17 +734,124 @@ function ServerManager() {
|
||||
onClose={handleLinkGeneratorClose}
|
||||
/>
|
||||
|
||||
{/* Add Server Modal */}
|
||||
<AddServerModal
|
||||
{/* Add Server Modal - New unified component */}
|
||||
<FormModal
|
||||
show={addServerModalOpen}
|
||||
newServer={newServer}
|
||||
customProvider={customProvider}
|
||||
onNewServerChange={setNewServer}
|
||||
onCustomProviderChange={setCustomProvider}
|
||||
onAddServer={handleAddServerFromModal}
|
||||
onClose={handleCloseAddServerModal}
|
||||
error={error}
|
||||
/>
|
||||
onSubmit={handleAddServerFromModal}
|
||||
title="Добавить новый сервер"
|
||||
submitLabel="Добавить сервер"
|
||||
submitIcon={IconPlus}
|
||||
size="md"
|
||||
>
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="IP адрес"
|
||||
name="ip"
|
||||
value={newServer.ip}
|
||||
onChange={(val) => setNewServer({ ...newServer, ip: val })}
|
||||
placeholder="192.168.1.1"
|
||||
required
|
||||
icon={IconServer}
|
||||
helpText="Введите IPv4 адрес сервера"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="DNS имя"
|
||||
name="dns"
|
||||
value={newServer.dns}
|
||||
onChange={(val) => setNewServer({ ...newServer, dns: val })}
|
||||
placeholder="srv1.shx.su"
|
||||
required
|
||||
icon={IconWorld}
|
||||
helpText="Полное доменное имя сервера"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Страна"
|
||||
name="country"
|
||||
type="select"
|
||||
value={newServer.country}
|
||||
onChange={(val) => setNewServer({ ...newServer, country: val })}
|
||||
required
|
||||
icon={IconMapPin}
|
||||
options={[
|
||||
{ value: '', label: 'Выберите страну', disabled: true },
|
||||
{ value: 'RU', label: 'Россия (RU)' },
|
||||
{ value: 'SWE', label: 'Швеция (SWE)' },
|
||||
{ value: 'US', label: 'США (US)' },
|
||||
{ value: 'DE', label: 'Германия (DE)' },
|
||||
{ value: 'NL', label: 'Нидерланды (NL)' },
|
||||
{ value: 'SG', label: 'Сингапур (SG)' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Провайдер"
|
||||
name="provider"
|
||||
type="select"
|
||||
value={newServer.provider}
|
||||
onChange={(val) => setNewServer({ ...newServer, provider: val })}
|
||||
required
|
||||
options={[
|
||||
{ value: '', label: 'Выберите провайдера', disabled: true },
|
||||
{ value: 'Yandex', label: 'Yandex Cloud' },
|
||||
{ value: 'AWS', label: 'Amazon Web Services' },
|
||||
{ value: 'Google', label: 'Google Cloud' },
|
||||
{ value: 'Azure', label: 'Microsoft Azure' },
|
||||
{ value: 'Hetzner', label: 'Hetzner' },
|
||||
{ value: 'OVH', label: 'OVH' },
|
||||
{ value: 'DigitalOcean', label: 'DigitalOcean' },
|
||||
{ value: 'Vultr', label: 'Vultr' },
|
||||
{ value: 'Linode', label: 'Linode' },
|
||||
{ value: 'VPSVILLE', label: 'VPSVILLE' },
|
||||
{ value: 'HIPHOST', label: 'HIPHOST' },
|
||||
{ value: 'Другой', label: 'Другой' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{newServer.provider === 'Другой' && (
|
||||
<FormField
|
||||
label="Название провайдера"
|
||||
name="customProvider"
|
||||
value={customProvider}
|
||||
onChange={setCustomProvider}
|
||||
placeholder="Введите название провайдера"
|
||||
required
|
||||
helpText="Укажите название вашего провайдера"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Тип туннеля"
|
||||
name="tunnel"
|
||||
type="select"
|
||||
value={newServer.tunnel}
|
||||
onChange={(val) => setNewServer({ ...newServer, tunnel: val })}
|
||||
required
|
||||
options={[
|
||||
{ value: 'GRE', label: 'GRE (Generic Routing Encapsulation)' },
|
||||
{ value: 'IPSec', label: 'IPSec' },
|
||||
{ value: 'WireGuard', label: 'WireGuard' },
|
||||
{ value: 'OpenVPN', label: 'OpenVPN' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Основной шлюз"
|
||||
name="gateway"
|
||||
value={newServer.gateway}
|
||||
onChange={(val) => setNewServer({ ...newServer, gateway: val })}
|
||||
placeholder="Cloudflare, Hetzner, Мой шлюз и т.д."
|
||||
required
|
||||
helpText="Имя основного шлюза для маршрутизации"
|
||||
/>
|
||||
</FormModal>
|
||||
|
||||
<div className="card w-100 mb-4">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Modal from './Modal';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* ConfirmModal - модальное окно подтверждения действия
|
||||
* Для критичных операций (удаление, отмена и т.д.)
|
||||
*/
|
||||
function ConfirmModal({
|
||||
show,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'Подтверждение',
|
||||
message,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
variant = 'danger', // primary, danger, warning, success
|
||||
icon: Icon = IconAlertTriangle,
|
||||
loading = false,
|
||||
showIcon = true
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-${variant}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{showIcon && Icon && (
|
||||
<div className="flex-shrink-0 me-3">
|
||||
<Icon className={`text-${variant}`} size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="mb-0">{message}</p>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmModal;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* FormField - универсальное поле формы с валидацией
|
||||
* Поддерживает иконки, подсказки, ошибки и success состояния
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
success,
|
||||
helpText,
|
||||
required,
|
||||
disabled,
|
||||
placeholder,
|
||||
icon: Icon,
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
rows, // для textarea
|
||||
options, // для select
|
||||
...inputProps
|
||||
}) {
|
||||
const inputId = `field-${name}`;
|
||||
const hasError = !!error;
|
||||
const hasSuccess = !!success && !error;
|
||||
const isTextarea = type === 'textarea';
|
||||
const isSelect = type === 'select';
|
||||
|
||||
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange?.(e.target.value, e);
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
if (isTextarea) {
|
||||
return (
|
||||
<textarea
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
rows={rows || 3}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSelect) {
|
||||
return (
|
||||
<select
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={inputClasses.replace('form-control', 'form-select')}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
>
|
||||
{options?.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
id={inputId}
|
||||
name={name}
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className}`}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="form-label">
|
||||
{label}
|
||||
{required && <span className="text-danger ms-1" aria-label="обязательное поле">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{Icon ? (
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{renderInput()}
|
||||
</div>
|
||||
) : (
|
||||
renderInput()
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSuccess && (
|
||||
<div className="valid-feedback d-block">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helpText && !error && !success && (
|
||||
<div id={`${inputId}-help`} className="form-text">
|
||||
{helpText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormField;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* FormModal - модальное окно с формой
|
||||
* Автоматически обрабатывает submit и отображает кнопки действий
|
||||
*/
|
||||
function FormModal({
|
||||
show,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
submitLabel = 'Сохранить',
|
||||
cancelLabel = 'Отмена',
|
||||
submitIcon: SubmitIcon,
|
||||
cancelIcon: CancelIcon,
|
||||
loading = false,
|
||||
submitVariant = 'primary',
|
||||
disabled = false,
|
||||
...modalProps
|
||||
}) {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit?.(e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Ctrl+Enter или Cmd+Enter для быстрого submit
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{CancelIcon && <CancelIcon className="icon me-2" />}
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-${submitVariant}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || disabled}
|
||||
>
|
||||
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
|
||||
{!loading && SubmitIcon && <SubmitIcon className="icon me-2" />}
|
||||
{submitLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
{...modalProps}
|
||||
>
|
||||
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
|
||||
{children}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormModal;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Универсальный Modal компонент
|
||||
* Использует нативный Tabler UI стиль
|
||||
*/
|
||||
function Modal({
|
||||
show,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md', // sm, md, lg, xl
|
||||
backdrop = true,
|
||||
keyboard = true,
|
||||
scrollable = false,
|
||||
centered = false,
|
||||
className = ''
|
||||
}) {
|
||||
const modalRef = useRef(null);
|
||||
const backdropRef = useRef(null);
|
||||
|
||||
// Управление классом modal-open на body
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
document.body.classList.add('modal-open');
|
||||
// Trap focus внутри модалки
|
||||
modalRef.current?.focus();
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
// Обработка ESC
|
||||
useEffect(() => {
|
||||
if (keyboard && show) {
|
||||
const handleEsc = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
}, [keyboard, show, onClose]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (backdrop && e.target === backdropRef.current) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="modal-backdrop fade show"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ zIndex: 1050 }}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`modal fade show d-block ${className}`}
|
||||
tabIndex="-1"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ zIndex: 1055 }}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${scrollable ? ' modal-dialog-scrollable' : ''}${centered ? ' modal-dialog-centered' : ''}`}>
|
||||
<div className="modal-content">
|
||||
{title && (
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="modal-footer">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* useDebounce - hook для debounce значения
|
||||
* Полезен для поиска и других операций, где нужно отложить выполнение
|
||||
*
|
||||
* @param {any} value - значение для debounce
|
||||
* @param {number} delay - задержка в миллисекундах (по умолчанию 300)
|
||||
* @returns {any} - debounced значение
|
||||
*
|
||||
* Пример использования:
|
||||
* const [searchTerm, setSearchTerm] = useState('');
|
||||
* const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* // API запрос с debounced значением
|
||||
* fetchResults(debouncedSearchTerm);
|
||||
* }, [debouncedSearchTerm]);
|
||||
*/
|
||||
function useDebounce(value, delay = 300) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем таймер для обновления значения
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Очищаем таймер при изменении value или delay
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounce;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useForm - hook для управления формами с валидацией
|
||||
*
|
||||
* @param {Object} initialValues - начальные значения полей
|
||||
* @param {Object} validationSchema - схема валидации {fieldName: validatorFn}
|
||||
* @returns {Object} - {values, errors, touched, isSubmitting, handlers}
|
||||
*
|
||||
* Пример использования:
|
||||
* const { values, errors, handleChange, handleBlur, handleSubmit } = useForm(
|
||||
* { email: '', password: '' },
|
||||
* {
|
||||
* email: (value) => !value ? 'Email обязателен' : null,
|
||||
* password: (value) => value.length < 6 ? 'Минимум 6 символов' : null
|
||||
* }
|
||||
* );
|
||||
*/
|
||||
function useForm(initialValues, validationSchema = {}) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [touched, setTouched] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Валидация одного поля
|
||||
const validateField = useCallback((name, value) => {
|
||||
if (!validationSchema[name]) return null;
|
||||
|
||||
const validator = validationSchema[name];
|
||||
const error = validator(value, values);
|
||||
|
||||
return error;
|
||||
}, [validationSchema, values]);
|
||||
|
||||
// Обработчик изменения поля
|
||||
const handleChange = useCallback((name, value) => {
|
||||
setValues(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Валидация при изменении, если поле уже было touched
|
||||
if (touched[name]) {
|
||||
const error = validateField(name, value);
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}
|
||||
}, [touched, validateField]);
|
||||
|
||||
// Обработчик blur (потеря фокуса)
|
||||
const handleBlur = useCallback((name) => {
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
|
||||
const value = values[name];
|
||||
const error = validateField(name, value);
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}, [values, validateField]);
|
||||
|
||||
// Валидация всех полей
|
||||
const validate = useCallback(() => {
|
||||
const newErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.keys(validationSchema).forEach(name => {
|
||||
const error = validateField(name, values[name]);
|
||||
if (error) {
|
||||
newErrors[name] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
}, [validationSchema, values, validateField]);
|
||||
|
||||
// Обработчик submit
|
||||
const handleSubmit = useCallback(async (onSubmit) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Отмечаем все поля как touched
|
||||
const allTouched = Object.keys(values).reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
setTouched(allTouched);
|
||||
|
||||
const isValid = validate();
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [values, validate]);
|
||||
|
||||
// Сброс формы
|
||||
const reset = useCallback(() => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
setIsSubmitting(false);
|
||||
}, [initialValues]);
|
||||
|
||||
// Установка значения поля программно
|
||||
const setValue = useCallback((name, value) => {
|
||||
setValues(prev => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
// Установка ошибки поля программно
|
||||
const setError = useCallback((name, error) => {
|
||||
setErrors(prev => ({ ...prev, [name]: error }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
validate,
|
||||
reset,
|
||||
setValue,
|
||||
setError,
|
||||
setValues,
|
||||
setErrors
|
||||
};
|
||||
}
|
||||
|
||||
export default useForm;
|
||||
|
||||
Reference in New Issue
Block a user