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
+28 -110
View File
@@ -15,6 +15,7 @@ import {
/**
* Command Palette - глобальный поиск по командам (Ctrl+K)
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false)
@@ -102,27 +103,25 @@ function CommandPalette() {
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="modal-backdrop fade show"
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={() => setIsOpen(false)}
style={{ zIndex: 1050 }}
></div>
{/* Modal */}
<div
className="modal fade show d-block"
tabIndex="-1"
style={{ zIndex: 1055 }}
>
<div className="modal-dialog modal-dialog-centered" style={{ maxWidth: '600px' }}>
<div className="modal-content">
<div
className="modal-dialog modal-dialog-centered"
style={{ maxWidth: '600px' }}
onClick={(e) => e.stopPropagation()}
role="document"
>
<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={20} />
<IconSearch size={18} />
</span>
<input
ref={inputRef}
@@ -139,10 +138,11 @@ function CommandPalette() {
</div>
{/* Commands list */}
<div className="modal-body pt-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<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">
@@ -151,20 +151,18 @@ function CommandPalette() {
return (
<button
key={index}
className={`list-group-item list-group-item-action d-flex align-items-center ${selectedIndex === index ? 'active' : ''}`}
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center${
index === selectedIndex ? ' 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>
<Icon size={20} className="me-3" />
<div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div>
<div className="text-muted small">{cmd.description}</div>
<small className="text-muted">{cmd.description}</small>
</div>
{selectedIndex === index && (
<kbd className="bg-white text-muted border">↵</kbd>
)}
</button>
)
})}
@@ -172,98 +170,18 @@ function CommandPalette() {
)}
</div>
{/* Footer with hints */}
{/* Footer with hint */}
<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>
<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>
</>
)}
</>
)
}
+54 -24
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,27 +16,62 @@ 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>
<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={`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();
}
footer={
<>
}
}}
>
<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}
>
<IconX size={16} className="me-1" />
{cancelText}
</button>
<button
@@ -49,15 +83,11 @@ export default function ConfirmDialog({
{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="text-muted">
<p className="mb-0">{message}</p>
</div>
</SmallModal>
</div>
</div>
</div>
);
}
+65 -63
View File
@@ -1,34 +1,86 @@
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>
<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="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();
}
footer={
<>
}
}}
>
<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 className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
<IconX size={16} className="me-1" />
Отмена
</button>
<button
@@ -40,62 +92,12 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
<IconCheck size={16} className="me-1" />
Сохранить
</button>
</>
}
>
<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>
</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>
</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>
);
}
+54 -34
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,45 +12,70 @@ 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>
<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="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();
}
footer={
<>
}
}}
>
<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}
>
<IconX size={16} className="me-1" />
{cancelLabel}
</button>
<button
@@ -62,16 +86,12 @@ function ConfirmModal({
{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="text-muted">
{typeof message === 'string' ? <p className="mb-0">{message}</p> : message}
</div>
</SmallModal>
</div>
</div>
</div>
);
}
+41 -22
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={
<>
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onKeyDown={handleKeyDown}
>
<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="icon me-2" />}
{CancelIcon && <CancelIcon className="me-2" size={16} />}
{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" />}
{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>
</>
}
{...modalProps}
>
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
{children}
</div>
</form>
</Modal>
</div>
</div>
</div>
);
}
export default FormModal;
+40 -31
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,37 +87,24 @@ function ImportModal({
};
return (
<Modal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<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="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>
}
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-body">
{/* Description */}
<div className="alert alert-info mb-3">
<div className="d-flex">
@@ -219,7 +207,28 @@ function ImportModal({
</div>
</div>
)}
</Modal>
</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>
</div>
);
}
+43 -34
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,40 +104,24 @@ export default function SettingsModal({ open, onClose }) {
};
return (
<Modal
show={open}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<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="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>
}
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-body">
{error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && (
@@ -176,6 +161,30 @@ export default function SettingsModal({ open, onClose }) {
helpText="HTTPS URL для DNS-over-HTTPS"
disabled={loading || saving}
/>
</Modal>
</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>
</div>
</div>
</div>
);
}
+39 -28
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,34 +118,25 @@ function WsUpdateModal({ show, url, onClose }) {
};
return (
<Modal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<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="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>
}
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-body">
{/* Controls */}
<div className="d-flex gap-2 mb-3">
<button
@@ -197,7 +189,26 @@ function WsUpdateModal({ show, url, onClose }) {
)}
<div ref={bottomRef} />
</div>
</Modal>
</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>
<button className="btn btn-secondary" onClick={onClose}>
<IconX size={16} className="me-1" />
Закрыть
</button>
</div>
</div>
</div>
</div>
</div>
);
}