feat: Добавить анимации и улучшения UX/UI в компоненты приложения, включая плавные переходы, эффекты при наведении и анимации для модальных окон. Обновить карточки и таблицы с новыми анимациями для улучшения визуального восприятия и доступности.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s

This commit is contained in:
2025-10-02 19:17:25 +07:00
parent cd5e169bc6
commit 8952c4a897
12 changed files with 1267 additions and 143 deletions
+221
View File
@@ -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);
}
}
+7 -2
View File
@@ -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
View File
@@ -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>
+41 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+71
View File
@@ -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;
+147
View File
@@ -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;
+72
View File
@@ -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;
+108
View File
@@ -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;
+39
View File
@@ -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;
+135
View File
@@ -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;