feat: Рефакторинг модальных окон с заменой на простой подход со встроенным backdrop для улучшения UX. Обновление компонентов CommandPalette, ConfirmDialog, ConfirmDiffModal, ConfirmModal, FormModal, ImportModal, SettingsModal и WsUpdateModal для повышения удобства использования и унификации интерфейса.
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m9s

This commit is contained in:
2025-10-03 12:52:39 +07:00
parent 9bff239035
commit f87d8f90c3
8 changed files with 659 additions and 641 deletions
+72 -154
View File
@@ -15,6 +15,7 @@ import {
/**
* Command Palette - глобальный поиск по командам (Ctrl+K)
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false)
@@ -102,169 +103,86 @@ function CommandPalette() {
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={() => setIsOpen(false)}
>
<div
className="modal-backdrop fade show"
onClick={() => setIsOpen(false)}
style={{ zIndex: 1050 }}
></div>
{/* Modal */}
<div
className="modal fade show d-block"
tabIndex="-1"
style={{ zIndex: 1055 }}
className="modal-dialog modal-dialog-centered"
style={{ maxWidth: '600px' }}
onClick={(e) => e.stopPropagation()}
role="document"
>
<div className="modal-dialog modal-dialog-centered" style={{ maxWidth: '600px' }}>
<div className="modal-content">
{/* Search input */}
<div className="modal-header border-0 pb-0">
<div className="input-icon w-100">
<span className="input-icon-addon">
<IconSearch size={20} />
</span>
<input
ref={inputRef}
type="text"
className="form-control form-control-lg border-0"
placeholder="Поиск команд..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setSelectedIndex(0)
}}
/>
</div>
<div className="modal-content" tabIndex={-1}>
{/* Search input */}
<div className="modal-header border-0 pb-0">
<div className="input-icon w-100">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
ref={inputRef}
type="text"
className="form-control form-control-lg border-0"
placeholder="Поиск команд..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setSelectedIndex(0)
}}
/>
</div>
</div>
{/* Commands list */}
<div className="modal-body pt-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{filteredCommands.length === 0 ? (
<div className="text-center text-muted py-4">
Команды не найдены
</div>
) : (
<div className="list-group list-group-flush">
{filteredCommands.map((cmd, index) => {
const Icon = cmd.icon
return (
<button
key={index}
className={`list-group-item list-group-item-action d-flex align-items-center ${selectedIndex === index ? 'active' : ''}`}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={`avatar avatar-sm me-3 ${selectedIndex === index ? 'bg-white text-primary' : 'bg-blue-lt text-blue'}`}>
<Icon size={20} />
</span>
<div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div>
<div className="text-muted small">{cmd.description}</div>
</div>
{selectedIndex === index && (
<kbd className="bg-white text-muted border"></kbd>
)}
</button>
)
})}
</div>
)}
</div>
{/* Footer with hints */}
<div className="modal-footer border-0 pt-0">
<div className="d-flex gap-3 text-muted small w-100 justify-content-center">
<div><kbd></kbd> <kbd></kbd> навигация</div>
<div><kbd></kbd> выбрать</div>
<div><kbd>Esc</kbd> закрыть</div>
{/* Commands list */}
<div className="modal-body pt-2">
{filteredCommands.length === 0 ? (
<div className="text-center text-muted py-4">
<IconSearch size={48} className="mb-2 opacity-50" />
<div>Команды не найдены</div>
</div>
) : (
<div className="list-group list-group-flush">
{filteredCommands.map((cmd, index) => {
const Icon = cmd.icon
return (
<button
key={index}
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center${
index === selectedIndex ? ' active' : ''
}`}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Icon size={20} className="me-3" />
<div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div>
<small className="text-muted">{cmd.description}</small>
</div>
</button>
)
})}
</div>
)}
</div>
{/* Footer with hint */}
<div className="modal-footer border-0 pt-0">
<div className="text-muted small d-flex align-items-center gap-2">
<IconKeyboard size={16} />
<span>
<kbd></kbd> <kbd></kbd> навигация <kbd>Enter</kbd> выбрать <kbd>ESC</kbd> закрыть
</span>
</div>
</div>
</div>
</div>
</>
</div>
)
}
/**
* Кнопка для показа shortcuts
*/
export function KeyboardShortcutsButton({ className = '' }) {
const [showModal, setShowModal] = useState(false)
const shortcuts = [
{ keys: ['Ctrl', 'K'], description: 'Открыть поиск команд' },
{ keys: ['Ctrl', 'N'], description: 'Добавить новую запись' },
{ keys: ['Ctrl', 'S'], description: 'Сохранить изменения' },
{ keys: ['Ctrl', 'F'], description: 'Поиск по таблице' },
{ keys: ['Esc'], description: 'Закрыть модалку / Отменить' },
{ keys: ['↑', '↓'], description: 'Навигация по списку' },
{ keys: ['Enter'], description: 'Подтвердить / Выбрать' },
]
// Если className содержит nav-link, используем ссылку вместо кнопки
const isNavLink = className.includes('nav-link')
return (
<>
{isNavLink ? (
<a
href="#"
className={className}
onClick={(e) => { e.preventDefault(); setShowModal(true); }}
title="Горячие клавиши (Ctrl+/)"
>
<IconKeyboard size={20} />
</a>
) : (
<button
className={`btn btn-ghost-secondary btn-icon ${className}`}
onClick={() => setShowModal(true)}
title="Горячие клавиши (Ctrl+/)"
>
<IconKeyboard size={20} />
</button>
)}
{showModal && (
<>
<div
className="modal-backdrop fade show"
onClick={() => setShowModal(false)}
></div>
<div className="modal fade show d-block" tabIndex="-1">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Горячие клавиши</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowModal(false)}
></button>
</div>
<div className="modal-body">
<div className="list-group list-group-flush">
{shortcuts.map((shortcut, index) => (
<div key={index} className="list-group-item d-flex justify-content-between align-items-center">
<span>{shortcut.description}</span>
<div className="d-flex gap-1">
{shortcut.keys.map((key, i) => (
<kbd key={i} className="bg-secondary-lt">{key}</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</>
)}
</>
)
}
export default CommandPalette
export default CommandPalette
+73 -43
View File
@@ -1,9 +1,8 @@
import { SmallModal } from './Modal';
import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react';
import { IconAlertTriangle } from '@tabler/icons-react';
/**
* ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений
* Алиас для ConfirmModal с другим API (для обратной совместимости)
* ConfirmDialog - упрощённая версия для быстрых подтверждений
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
export default function ConfirmDialog({
open,
@@ -17,47 +16,78 @@ export default function ConfirmDialog({
size = 'sm',
loading = false
}) {
if (!open) return null;
return (
<SmallModal
show={open}
onClose={onCancel}
title={
<div className="d-flex align-items-center">
{destructive && (
<IconAlertTriangle className="text-danger me-2" size={24} />
)}
{title}
</div>
}
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
<IconX size={16} className="me-1" />
{cancelText}
</button>
<button
type="button"
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
<IconCheck size={16} className="me-1" />
{confirmText}
</button>
</>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.(); }}
>
<div className="text-muted">
<p className="mb-0">{message}</p>
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''} modal-dialog-centered`} role="document">
<div
className="modal-content"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
>
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button type="button" className="btn-close" onClick={onCancel} />
</div>
<div className="modal-body">
<div className="d-flex align-items-start">
{destructive && (
<div className="flex-shrink-0 me-3">
<IconAlertTriangle className="text-danger" size={32} />
</div>
)}
<div className="flex-grow-1">
<p className="mb-0">{message}</p>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{confirmText}
</button>
</div>
</div>
</div>
</SmallModal>
</div>
);
}
+81 -79
View File
@@ -1,101 +1,103 @@
import { SmallModal } from './Modal';
import { IconCheck, IconX, IconRefresh } from '@tabler/icons-react';
/**
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
* Использует готовый пресет SmallModal для компактного отображения
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
const added = diff?.added?.length || 0;
const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0;
if (!show) return null;
return (
<SmallModal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<IconRefresh className="me-2" size={24} />
Подтвердить сохранение
</div>
}
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
<IconX size={16} className="me-1" />
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
<IconCheck size={16} className="me-1" />
Сохранить
</button>
</>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
<div className="row g-2 text-center">
<div className="col">
<div className="card bg-green-lt">
<div className="card-body p-3">
<div className="d-flex align-items-center justify-content-center">
<IconCheck size={20} className="text-green me-2" />
<div className="text-start">
<div className="text-muted small">Добавлено</div>
<div className="h2 m-0 text-green">{added}</div>
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div
className="modal-content"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
>
<div className="modal-header">
<h5 className="modal-title">Подтвердить сохранение</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
<div className="row g-2 text-center">
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Добавлено</strong>
<div className="text-muted">{added}</div>
</div>
</div>
</div>
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Удалено</strong>
<div className="text-muted">{removed}</div>
</div>
</div>
</div>
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Изменено</strong>
<div className="text-muted">{changed}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="col">
<div className="card bg-red-lt">
<div className="card-body p-3">
<div className="d-flex align-items-center justify-content-center">
<IconX size={20} className="text-red me-2" />
<div className="text-start">
<div className="text-muted small">Удалено</div>
<div className="h2 m-0 text-red">{removed}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col">
<div className="card bg-blue-lt">
<div className="card-body p-3">
<div className="d-flex align-items-center justify-content-center">
<IconRefresh size={20} className="text-blue me-2" />
<div className="text-start">
<div className="text-muted small">Изменено</div>
<div className="h2 m-0 text-blue">{changed}</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
Сохранить
</button>
</div>
</div>
</div>
{(added > 0 || removed > 0 || changed > 0) && (
<div className="alert alert-info mt-3 mb-0">
<div className="text-muted small">
Всего будет изменено <strong>{added + removed + changed}</strong> записей
</div>
</div>
)}
</SmallModal>
</div>
);
}
+71 -51
View File
@@ -1,9 +1,8 @@
import { SmallModal } from './Modal';
import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react';
import { IconAlertTriangle } from '@tabler/icons-react';
/**
* ConfirmModal - модальное окно подтверждения действия
* Использует SmallModal для компактного отображения
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function ConfirmModal({
show,
@@ -13,65 +12,86 @@ function ConfirmModal({
message,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
variant = 'danger', // primary, danger, warning, success
variant = 'danger',
icon: Icon = IconAlertTriangle,
loading = false,
showIcon = true
}) {
if (!show) return null;
const handleConfirm = () => {
onConfirm?.();
};
const getIconColor = () => {
switch (variant) {
case 'danger': return 'text-danger';
case 'warning': return 'text-warning';
case 'success': return 'text-success';
case 'primary': return 'text-primary';
default: return 'text-danger';
}
};
return (
<SmallModal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
{showIcon && Icon && (
<Icon className={`me-2 ${getIconColor()}`} size={24} />
)}
{title}
</div>
}
footer={
<>
<button
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
<IconX size={16} className="me-1" />
{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" />
)}
<IconCheck size={16} className="me-1" />
{confirmLabel}
</button>
</>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
<div className="text-muted">
{typeof message === 'string' ? <p className="mb-0">{message}</p> : message}
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div
className="modal-content"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
>
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
<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>
</div>
<div className="modal-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>
</div>
</div>
</SmallModal>
</div>
);
}
+56 -37
View File
@@ -1,8 +1,6 @@
import Modal from './Modal';
/**
* FormModal - модальное окно с формой
* Автоматически обрабатывает submit и отображает кнопки действий
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function FormModal({
show,
@@ -17,8 +15,11 @@ function FormModal({
loading = false,
submitVariant = 'primary',
disabled = false,
...modalProps
size = 'md',
centered = false
}) {
if (!show) return null;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit?.(e);
@@ -29,44 +30,62 @@ function FormModal({
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSubmit(e);
}
// ESC для закрытия
if (e.key === 'Escape') {
onClose?.();
}
};
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}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={handleKeyDown}
>
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
{children}
</form>
</Modal>
<div
className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${centered ? ' modal-dialog-centered' : ''}`}
role="document"
>
<div className="modal-content" tabIndex={-1}>
<form onSubmit={handleSubmit}>
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
{children}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
{CancelIcon && <CancelIcon className="me-2" size={16} />}
{cancelLabel}
</button>
<button
type="submit"
className={`btn btn-${submitVariant}`}
disabled={loading || disabled}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{!loading && SubmitIcon && <SubmitIcon className="me-2" size={16} />}
{submitLabel}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
export default FormModal;
+133 -124
View File
@@ -1,10 +1,9 @@
import Modal from './Modal';
import { useEffect, useRef, useState } from 'react';
import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
/**
* ImportModal - модальное окно импорта данных
* Рефакторинг: использует базовый Modal, улучшенный UX
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function ImportModal({
show,
@@ -33,6 +32,8 @@ function ImportModal({
}
}, [show]);
if (!show) return null;
const parseText = (raw) => {
const lines = String(raw || '')
.split(/\r?\n/)
@@ -86,140 +87,148 @@ function ImportModal({
};
return (
<Modal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<IconUpload className="me-2" size={24} />
{title}
</div>
}
size="lg"
centered
footer={
<>
<button className="btn btn-outline-secondary" onClick={downloadSample}>
<IconFileText size={16} className="me-1" />
Скачать шаблон
</button>
<button className="btn" onClick={onClose}>
Отмена
</button>
<button
className="btn btn-primary"
disabled={parsed.items.length === 0}
onClick={handleConfirm}
>
<IconCheck size={16} className="me-1" />
Импортировать ({parsed.items.length})
</button>
</>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
{/* Description */}
<div className="alert alert-info mb-3">
<div className="d-flex">
<IconAlertCircle className="me-2 flex-shrink-0" size={20} />
<div className="text-muted small">{description}</div>
</div>
</div>
{/* Drop zone */}
<div
className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between">
<div className="text-muted">
{fileName ? (
<div className="d-flex align-items-center">
<IconFileText size={20} className="me-2 text-primary" />
<strong>Файл:</strong> {fileName}
</div>
) : (
'Перетащите TXT/CSV сюда или вставьте текст ниже'
)}
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<IconUpload className="me-2" size={24} />
{title}
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
{/* Description */}
<div className="alert alert-info mb-3">
<div className="d-flex">
<IconAlertCircle className="me-2 flex-shrink-0" size={20} />
<div className="text-muted small">{description}</div>
</div>
</div>
<label className="btn btn-outline-primary mb-0">
<IconUpload size={16} className="me-1" />
Выбрать файл
<input
type="file"
accept=".txt,.csv,.log"
hidden
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setFileName(f.name);
const t = await f.text();
setText(t);
parseText(t);
}}
{/* Drop zone */}
<div
className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between">
<div className="text-muted">
{fileName ? (
<div className="d-flex align-items-center">
<IconFileText size={20} className="me-2 text-primary" />
<strong>Файл:</strong> {fileName}
</div>
) : (
'Перетащите TXT/CSV сюда или вставьте текст ниже'
)}
</div>
<label className="btn btn-outline-primary mb-0">
<IconUpload size={16} className="me-1" />
Выбрать файл
<input
type="file"
accept=".txt,.csv,.log"
hidden
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setFileName(f.name);
const t = await f.text();
setText(t);
parseText(t);
}}
/>
</label>
</div>
</div>
</div>
{/* Textarea */}
<div className="mb-3">
<textarea
ref={textAreaRef}
className="form-control font-monospace"
rows={8}
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
</label>
</div>
</div>
</div>
</div>
{/* Textarea */}
<div className="mb-3">
<textarea
ref={textAreaRef}
className="form-control font-monospace"
rows={8}
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconCheck size={20} className="text-green me-2" />
<div>
<div className="text-muted small">Готово к импорту</div>
<div className="h3 m-0 text-green">{parsed.items.length}</div>
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconCheck size={20} className="text-green me-2" />
<div>
<div className="text-muted small">Готово к импорту</div>
<div className="h3 m-0 text-green">{parsed.items.length}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconX size={20} className="text-red me-2" />
<div>
<div className="text-muted small">Пропущено (ошибки)</div>
<div className="h3 m-0 text-red">{parsed.invalid.length}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconX size={20} className="text-red me-2" />
<div>
<div className="text-muted small">Пропущено (ошибки)</div>
<div className="h3 m-0 text-red">{parsed.invalid.length}</div>
{parsed.invalid.length > 0 && (
<div className="alert alert-warning mt-3">
<div className="text-muted small">
<strong>Некорректные строки (показаны первые 5):</strong>
<ul className="mb-0 mt-1">
{parsed.invalid.slice(0, 5).map((line, i) => (
<li key={i}><code>{line}</code></li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={downloadSample}>
<IconFileText size={16} className="me-1" />
Скачать шаблон
</button>
<button className="btn" onClick={onClose}>
Отмена
</button>
<button
className="btn btn-primary"
disabled={parsed.items.length === 0}
onClick={handleConfirm}
>
<IconCheck size={16} className="me-1" />
Импортировать ({parsed.items.length})
</button>
</div>
</div>
</div>
{parsed.invalid.length > 0 && (
<div className="alert alert-warning mt-3">
<div className="text-muted small">
<strong>Некорректные строки (показаны первые 5):</strong>
<ul className="mb-0 mt-1">
{parsed.invalid.slice(0, 5).map((line, i) => (
<li key={i}><code>{line}</code></li>
))}
</ul>
</div>
</div>
)}
</Modal>
</div>
);
}
+82 -73
View File
@@ -1,4 +1,3 @@
import Modal from './Modal';
import FormField from './FormField';
import { useEffect, useState } from 'react';
import api from '../lib/api.js';
@@ -7,7 +6,7 @@ import ErrorAlert from './ErrorAlert';
/**
* SettingsModal - модальное окно настроек интерфейса
* Рефакторинг: использует базовый Modal и FormField
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
export default function SettingsModal({ open, onClose }) {
const [loading, setLoading] = useState(false);
@@ -40,6 +39,8 @@ export default function SettingsModal({ open, onClose }) {
})();
}, [open]);
if (!open) return null;
const validateDoh = (value) => {
if (!value) return { valid: true, message: '' };
try {
@@ -103,79 +104,87 @@ export default function SettingsModal({ open, onClose }) {
};
return (
<Modal
show={open}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<IconSettings className="me-2" size={24} />
Настройки интерфейса
</div>
}
size="md"
centered
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={saving}
>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={16} className="me-1" />
Сохранить
</button>
</>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
{error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div className="flex-grow-1">{success}</div>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<IconSettings className="me-2" size={24} />
Настройки интерфейса
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
{error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div className="flex-grow-1">{success}</div>
</div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
/>
</div>
)}
<FormField
label="WebSocket URL (BGP Live)"
name="wsUrl"
type="text"
value={wsUrl}
onChange={setWsUrl}
onValidate={validateWs}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
helpText="URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым."
disabled={loading || saving}
autoFocus
/>
<FormField
label="DoH сервер"
name="dohServer"
type="text"
value={dohServer}
onChange={setDohServer}
onValidate={validateDoh}
placeholder="https://dns.google/dns-query"
helpText="HTTPS URL для DNS-over-HTTPS"
disabled={loading || saving}
/>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={saving}
>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={16} className="me-1" />
Сохранить
</button>
</div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
/>
</div>
)}
<FormField
label="WebSocket URL (BGP Live)"
name="wsUrl"
type="text"
value={wsUrl}
onChange={setWsUrl}
onValidate={validateWs}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
helpText="URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым."
disabled={loading || saving}
autoFocus
/>
<FormField
label="DoH сервер"
name="dohServer"
type="text"
value={dohServer}
onChange={setDohServer}
onValidate={validateDoh}
placeholder="https://dns.google/dns-query"
helpText="HTTPS URL для DNS-over-HTTPS"
disabled={loading || saving}
/>
</Modal>
</div>
</div>
);
}
+91 -80
View File
@@ -1,4 +1,3 @@
import Modal from './Modal';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
IconX,
@@ -12,11 +11,11 @@ import {
/**
* WsUpdateModal - модальное окно с логами WebSocket
* Рефакторинг: использует базовый Modal, улучшенный UX
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function WsUpdateModal({ show, url, onClose }) {
const [rawMessages, setRawMessages] = useState([]);
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
const [status, setStatus] = useState('connecting');
const wsRef = useRef(null);
const bottomRef = useRef(null);
const [autoScroll, setAutoScroll] = useState(true);
@@ -92,6 +91,8 @@ function WsUpdateModal({ show, url, onClose }) {
return lastTs - startedAt.getTime();
}, [startedAt, rawMessages]);
if (!show) return null;
const copyLog = async () => {
try {
await navigator.clipboard.writeText(plainLog);
@@ -117,87 +118,97 @@ function WsUpdateModal({ show, url, onClose }) {
};
return (
<Modal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<IconPlugConnected className="me-2" size={24} />
Логи запуска
{getStatusBadge()}
</div>
}
size="lg"
scrollable={false}
footer={
<div className="d-flex justify-content-between align-items-center w-100">
<div className="text-muted small d-flex align-items-center">
<IconClock size={16} className="me-2" />
<span>
Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
{elapsedMs != null && <> Прошло {elapsedMs} ms</>}
</span>
</div>
<button className="btn btn-secondary" onClick={onClose}>
<IconX size={16} className="me-1" />
Закрыть
</button>
</div>
}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
{/* Controls */}
<div className="d-flex gap-2 mb-3">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}
>
{autoScroll ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
<span className="ms-1">Автопрокрутка</span>
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={copyLog}
title="Скопировать лог"
>
<IconCopy size={16} />
<span className="ms-1">Копировать</span>
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearLog}
title="Очистить лог"
>
<IconTrash size={16} />
<span className="ms-1">Очистить</span>
</button>
</div>
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<IconPlugConnected className="me-2" size={24} />
Логи запуска
{getStatusBadge()}
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div>
<div className="modal-body">
{/* Controls */}
<div className="d-flex gap-2 mb-3">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}
>
{autoScroll ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
<span className="ms-1">Автопрокрутка</span>
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={copyLog}
title="Скопировать лог"
>
<IconCopy size={16} />
<span className="ms-1">Копировать</span>
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearLog}
title="Очистить лог"
>
<IconTrash size={16} />
<span className="ms-1">Очистить</span>
</button>
</div>
{/* Log output */}
<div
className="bg-dark text-light p-3 rounded font-monospace"
style={{
maxHeight: '60vh',
overflowY: 'auto',
fontSize: '0.875rem',
border: '1px solid rgba(255,255,255,0.08)'
}}
>
{rawMessages.length === 0 ? (
<div className="text-muted">Ожидание сообщений от сервера...</div>
) : (
rawMessages.map((m, i) => {
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
return (
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
<span className="text-secondary">$</span> {m.text}
{/* Log output */}
<div
className="bg-dark text-light p-3 rounded font-monospace"
style={{
maxHeight: '60vh',
overflowY: 'auto',
fontSize: '0.875rem',
border: '1px solid rgba(255,255,255,0.08)'
}}
>
{rawMessages.length === 0 ? (
<div className="text-muted">Ожидание сообщений от сервера...</div>
) : (
rawMessages.map((m, i) => {
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
return (
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
<span className="text-secondary">$</span> {m.text}
</div>
);
})
)}
<div ref={bottomRef} />
</div>
</div>
<div className="modal-footer">
<div className="d-flex justify-content-between align-items-center w-100">
<div className="text-muted small d-flex align-items-center">
<IconClock size={16} className="me-2" />
<span>
Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
{elapsedMs != null && <> Прошло {elapsedMs} ms</>}
</span>
</div>
);
})
)}
<div ref={bottomRef} />
<button className="btn btn-secondary" onClick={onClose}>
<IconX size={16} className="me-1" />
Закрыть
</button>
</div>
</div>
</div>
</div>
</Modal>
</div>
);
}