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) * Command Palette - глобальный поиск по командам (Ctrl+K)
* Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function CommandPalette() { function CommandPalette() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -102,27 +103,25 @@ function CommandPalette() {
if (!isOpen) return null if (!isOpen) return null
return ( return (
<>
{/* Backdrop */}
<div <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)} 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
<div className="modal-content"> className="modal-dialog modal-dialog-centered"
style={{ maxWidth: '600px' }}
onClick={(e) => e.stopPropagation()}
role="document"
>
<div className="modal-content" tabIndex={-1}>
{/* Search input */} {/* Search input */}
<div className="modal-header border-0 pb-0"> <div className="modal-header border-0 pb-0">
<div className="input-icon w-100"> <div className="input-icon w-100">
<span className="input-icon-addon"> <span className="input-icon-addon">
<IconSearch size={20} /> <IconSearch size={18} />
</span> </span>
<input <input
ref={inputRef} ref={inputRef}
@@ -139,10 +138,11 @@ function CommandPalette() {
</div> </div>
{/* Commands list */} {/* Commands list */}
<div className="modal-body pt-2" style={{ maxHeight: '400px', overflowY: 'auto' }}> <div className="modal-body pt-2">
{filteredCommands.length === 0 ? ( {filteredCommands.length === 0 ? (
<div className="text-center text-muted py-4"> <div className="text-center text-muted py-4">
Команды не найдены <IconSearch size={48} className="mb-2 opacity-50" />
<div>Команды не найдены</div>
</div> </div>
) : ( ) : (
<div className="list-group list-group-flush"> <div className="list-group list-group-flush">
@@ -151,20 +151,18 @@ function CommandPalette() {
return ( return (
<button <button
key={index} 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)} onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
> >
<span className={`avatar avatar-sm me-3 ${selectedIndex === index ? 'bg-white text-primary' : 'bg-blue-lt text-blue'}`}> <Icon size={20} className="me-3" />
<Icon size={20} />
</span>
<div className="flex-grow-1 text-start"> <div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div> <div className="fw-bold">{cmd.label}</div>
<div className="text-muted small">{cmd.description}</div> <small className="text-muted">{cmd.description}</small>
</div> </div>
{selectedIndex === index && (
<kbd className="bg-white text-muted border"></kbd>
)}
</button> </button>
) )
})} })}
@@ -172,98 +170,18 @@ function CommandPalette() {
)} )}
</div> </div>
{/* Footer with hints */} {/* Footer with hint */}
<div className="modal-footer border-0 pt-0"> <div className="modal-footer border-0 pt-0">
<div className="d-flex gap-3 text-muted small w-100 justify-content-center"> <div className="text-muted small d-flex align-items-center gap-2">
<div><kbd></kbd> <kbd></kbd> навигация</div> <IconKeyboard size={16} />
<div><kbd></kbd> выбрать</div> <span>
<div><kbd>Esc</kbd> закрыть</div> <kbd></kbd> <kbd></kbd> навигация <kbd>Enter</kbd> выбрать <kbd>ESC</kbd> закрыть
</span>
</div> </div>
</div> </div>
</div> </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 } from '@tabler/icons-react';
import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react';
/** /**
* ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений * ConfirmDialog - упрощённая версия для быстрых подтверждений
* Алиас для ConfirmModal с другим API (для обратной совместимости) * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
export default function ConfirmDialog({ export default function ConfirmDialog({
open, open,
@@ -17,27 +16,62 @@ export default function ConfirmDialog({
size = 'sm', size = 'sm',
loading = false loading = false
}) { }) {
if (!open) return null;
return ( return (
<SmallModal <div
show={open} className="modal show d-block"
onClose={onCancel} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
{destructive && ( onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.(); }}
<IconAlertTriangle className="text-danger me-2" size={24} /> >
)} <div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''} modal-dialog-centered`} role="document">
{title} <div
</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 <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={onCancel} onClick={onCancel}
disabled={loading} disabled={loading}
> >
<IconX size={16} className="me-1" />
{cancelText} {cancelText}
</button> </button>
<button <button
@@ -49,15 +83,11 @@ export default function ConfirmDialog({
{loading && ( {loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" /> <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)} )}
<IconCheck size={16} className="me-1" />
{confirmText} {confirmText}
</button> </button>
</>
}
>
<div className="text-muted">
<p className="mb-0">{message}</p>
</div> </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'; import { IconCheck, IconX, IconRefresh } from '@tabler/icons-react';
/** /**
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики * ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
* Использует готовый пресет SmallModal для компактного отображения * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) { function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
const added = diff?.added?.length || 0; const added = diff?.added?.length || 0;
const removed = diff?.removed?.length || 0; const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0; const changed = diff?.changed?.length || 0;
if (!show) return null;
return ( return (
<SmallModal <div
show={show} className="modal show d-block"
onClose={onClose} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
<IconRefresh className="me-2" size={24} /> onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
Подтвердить сохранение >
</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();
} }
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 <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
> >
<IconX size={16} className="me-1" />
Отмена Отмена
</button> </button>
<button <button
@@ -40,62 +92,12 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
{loading && ( {loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" /> <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)} )}
<IconCheck size={16} className="me-1" />
Сохранить Сохранить
</button> </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>
</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 } from '@tabler/icons-react';
import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react';
/** /**
* ConfirmModal - модальное окно подтверждения действия * ConfirmModal - модальное окно подтверждения действия
* Использует SmallModal для компактного отображения * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function ConfirmModal({ function ConfirmModal({
show, show,
@@ -13,45 +12,70 @@ function ConfirmModal({
message, message,
confirmLabel = 'Подтвердить', confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
variant = 'danger', // primary, danger, warning, success variant = 'danger',
icon: Icon = IconAlertTriangle, icon: Icon = IconAlertTriangle,
loading = false, loading = false,
showIcon = true showIcon = true
}) { }) {
if (!show) return null;
const handleConfirm = () => { const handleConfirm = () => {
onConfirm?.(); 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 ( return (
<SmallModal <div
show={show} className="modal show d-block"
onClose={onClose} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
{showIcon && Icon && ( onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
<Icon className={`me-2 ${getIconColor()}`} size={24} /> >
)} <div className="modal-dialog modal-sm modal-dialog-centered" role="document">
{title} <div
</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 <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
> >
<IconX size={16} className="me-1" />
{cancelLabel} {cancelLabel}
</button> </button>
<button <button
@@ -62,16 +86,12 @@ function ConfirmModal({
{loading && ( {loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" /> <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)} )}
<IconCheck size={16} className="me-1" />
{confirmLabel} {confirmLabel}
</button> </button>
</>
}
>
<div className="text-muted">
{typeof message === 'string' ? <p className="mb-0">{message}</p> : message}
</div> </div>
</SmallModal> </div>
</div>
</div>
); );
} }
+41 -22
View File
@@ -1,8 +1,6 @@
import Modal from './Modal';
/** /**
* FormModal - модальное окно с формой * FormModal - модальное окно с формой
* Автоматически обрабатывает submit и отображает кнопки действий * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function FormModal({ function FormModal({
show, show,
@@ -17,8 +15,11 @@ function FormModal({
loading = false, loading = false,
submitVariant = 'primary', submitVariant = 'primary',
disabled = false, disabled = false,
...modalProps size = 'md',
centered = false
}) { }) {
if (!show) return null;
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
onSubmit?.(e); onSubmit?.(e);
@@ -29,44 +30,62 @@ function FormModal({
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSubmit(e); handleSubmit(e);
} }
// ESC для закрытия
if (e.key === 'Escape') {
onClose?.();
}
}; };
return ( return (
<Modal <div
show={show} className="modal show d-block"
onClose={onClose} role="dialog"
title={title} aria-modal="true"
footer={ 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 <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
> >
{CancelIcon && <CancelIcon className="icon me-2" />} {CancelIcon && <CancelIcon className="me-2" size={16} />}
{cancelLabel} {cancelLabel}
</button> </button>
<button <button
type="submit" type="submit"
className={`btn btn-${submitVariant}`} className={`btn btn-${submitVariant}`}
onClick={handleSubmit}
disabled={loading || disabled} disabled={loading || disabled}
> >
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />} {loading && (
{!loading && SubmitIcon && <SubmitIcon className="icon me-2" />} <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{!loading && SubmitIcon && <SubmitIcon className="me-2" size={16} />}
{submitLabel} {submitLabel}
</button> </button>
</> </div>
}
{...modalProps}
>
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
{children}
</form> </form>
</Modal> </div>
</div>
</div>
); );
} }
export default FormModal; export default FormModal;
+40 -31
View File
@@ -1,10 +1,9 @@
import Modal from './Modal';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react'; import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
/** /**
* ImportModal - модальное окно импорта данных * ImportModal - модальное окно импорта данных
* Рефакторинг: использует базовый Modal, улучшенный UX * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function ImportModal({ function ImportModal({
show, show,
@@ -33,6 +32,8 @@ function ImportModal({
} }
}, [show]); }, [show]);
if (!show) return null;
const parseText = (raw) => { const parseText = (raw) => {
const lines = String(raw || '') const lines = String(raw || '')
.split(/\r?\n/) .split(/\r?\n/)
@@ -86,37 +87,24 @@ function ImportModal({
}; };
return ( return (
<Modal <div
show={show} className="modal show d-block"
onClose={onClose} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> 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} /> <IconUpload className="me-2" size={24} />
{title} {title}
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div> </div>
}
size="lg" <div className="modal-body">
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>
</>
}
>
{/* Description */} {/* Description */}
<div className="alert alert-info mb-3"> <div className="alert alert-info mb-3">
<div className="d-flex"> <div className="d-flex">
@@ -219,7 +207,28 @@ function ImportModal({
</div> </div>
</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 FormField from './FormField';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import api from '../lib/api.js'; import api from '../lib/api.js';
@@ -7,7 +6,7 @@ import ErrorAlert from './ErrorAlert';
/** /**
* SettingsModal - модальное окно настроек интерфейса * SettingsModal - модальное окно настроек интерфейса
* Рефакторинг: использует базовый Modal и FormField * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
export default function SettingsModal({ open, onClose }) { export default function SettingsModal({ open, onClose }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -40,6 +39,8 @@ export default function SettingsModal({ open, onClose }) {
})(); })();
}, [open]); }, [open]);
if (!open) return null;
const validateDoh = (value) => { const validateDoh = (value) => {
if (!value) return { valid: true, message: '' }; if (!value) return { valid: true, message: '' };
try { try {
@@ -103,40 +104,24 @@ export default function SettingsModal({ open, onClose }) {
}; };
return ( return (
<Modal <div
show={open} className="modal show d-block"
onClose={onClose} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> 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} /> <IconSettings className="me-2" size={24} />
Настройки интерфейса Настройки интерфейса
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div> </div>
}
size="md" <div className="modal-body">
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>
</>
}
>
{error && <ErrorAlert message={error} onClose={() => setError('')} />} {error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && ( {success && (
@@ -176,6 +161,30 @@ export default function SettingsModal({ open, onClose }) {
helpText="HTTPS URL для DNS-over-HTTPS" helpText="HTTPS URL для DNS-over-HTTPS"
disabled={loading || saving} 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 { useEffect, useMemo, useRef, useState } from 'react';
import { import {
IconX, IconX,
@@ -12,11 +11,11 @@ import {
/** /**
* WsUpdateModal - модальное окно с логами WebSocket * WsUpdateModal - модальное окно с логами WebSocket
* Рефакторинг: использует базовый Modal, улучшенный UX * Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function WsUpdateModal({ show, url, onClose }) { function WsUpdateModal({ show, url, onClose }) {
const [rawMessages, setRawMessages] = useState([]); const [rawMessages, setRawMessages] = useState([]);
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error const [status, setStatus] = useState('connecting');
const wsRef = useRef(null); const wsRef = useRef(null);
const bottomRef = useRef(null); const bottomRef = useRef(null);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
@@ -92,6 +91,8 @@ function WsUpdateModal({ show, url, onClose }) {
return lastTs - startedAt.getTime(); return lastTs - startedAt.getTime();
}, [startedAt, rawMessages]); }, [startedAt, rawMessages]);
if (!show) return null;
const copyLog = async () => { const copyLog = async () => {
try { try {
await navigator.clipboard.writeText(plainLog); await navigator.clipboard.writeText(plainLog);
@@ -117,34 +118,25 @@ function WsUpdateModal({ show, url, onClose }) {
}; };
return ( return (
<Modal <div
show={show} className="modal show d-block"
onClose={onClose} role="dialog"
title={ aria-modal="true"
<div className="d-flex align-items-center"> 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} /> <IconPlugConnected className="me-2" size={24} />
Логи запуска Логи запуска
{getStatusBadge()} {getStatusBadge()}
</h5>
<button type="button" className="btn-close" onClick={onClose} />
</div> </div>
}
size="lg" <div className="modal-body">
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>
}
>
{/* Controls */} {/* Controls */}
<div className="d-flex gap-2 mb-3"> <div className="d-flex gap-2 mb-3">
<button <button
@@ -197,7 +189,26 @@ function WsUpdateModal({ show, url, onClose }) {
)} )}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </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>
); );
} }