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) * Command Palette - глобальный поиск по командам (Ctrl+K)
* Простой подход со встроенным backdrop (как в HistoryModal)
*/ */
function CommandPalette() { function CommandPalette() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -102,169 +103,86 @@ function CommandPalette() {
if (!isOpen) return null if (!isOpen) return null
return ( return (
<> <div
{/* Backdrop */} className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={() => setIsOpen(false)}
>
<div <div
className="modal-backdrop fade show" className="modal-dialog modal-dialog-centered"
onClick={() => setIsOpen(false)} style={{ maxWidth: '600px' }}
style={{ zIndex: 1050 }} onClick={(e) => e.stopPropagation()}
></div> role="document"
{/* 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" tabIndex={-1}>
<div className="modal-content"> {/* 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={18} />
<IconSearch size={20} /> </span>
</span> <input
<input ref={inputRef}
ref={inputRef} type="text"
type="text" className="form-control form-control-lg border-0"
className="form-control form-control-lg border-0" placeholder="Поиск команд..."
placeholder="Поиск команд..." value={searchTerm}
value={searchTerm} onChange={(e) => {
onChange={(e) => { setSearchTerm(e.target.value)
setSearchTerm(e.target.value) setSelectedIndex(0)
setSelectedIndex(0) }}
}} />
/>
</div>
</div> </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 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>
</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> </div>
</div> </div>
</> </div>
) )
} }
/** export default CommandPalette
* Кнопка для показа 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
+73 -43
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,47 +16,78 @@ 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} />
)}
{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="text-muted"> <div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''} modal-dialog-centered`} role="document">
<p className="mb-0">{message}</p> <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> </div>
</SmallModal> </div>
); );
} }
+81 -79
View File
@@ -1,101 +1,103 @@
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>
}
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="row g-2 text-center"> <div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="col"> <div
<div className="card bg-green-lt"> className="modal-content"
<div className="card-body p-3"> tabIndex={-1}
<div className="d-flex align-items-center justify-content-center"> onKeyDown={(e) => {
<IconCheck size={20} className="text-green me-2" /> if (e.key === 'Tab') {
<div className="text-start"> const c = e.currentTarget;
<div className="text-muted small">Добавлено</div> const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
<div className="h2 m-0 text-green">{added}</div> 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>
</div> </div>
</div>
<div className="col"> <div className="modal-footer">
<div className="card bg-red-lt"> <button
<div className="card-body p-3"> type="button"
<div className="d-flex align-items-center justify-content-center"> className="btn btn-secondary"
<IconX size={20} className="text-red me-2" /> onClick={onClose}
<div className="text-start"> disabled={loading}
<div className="text-muted small">Удалено</div> >
<div className="h2 m-0 text-red">{removed}</div> Отмена
</div> </button>
</div> <button
</div> type="button"
</div> className="btn btn-primary"
</div> onClick={onConfirm}
<div className="col"> disabled={loading}
<div className="card bg-blue-lt"> >
<div className="card-body p-3"> {loading && (
<div className="d-flex align-items-center justify-content-center"> <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
<IconRefresh size={20} className="text-blue me-2" /> )}
<div className="text-start"> Сохранить
<div className="text-muted small">Изменено</div> </button>
<div className="h2 m-0 text-blue">{changed}</div>
</div>
</div>
</div>
</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>
); );
} }
+71 -51
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,65 +12,86 @@ 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} />
)}
{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="text-muted"> <div className="modal-dialog modal-sm modal-dialog-centered" role="document">
{typeof message === 'string' ? <p className="mb-0">{message}</p> : message} <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> </div>
</SmallModal> </div>
); );
} }
+56 -37
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}
<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}> <div
{children} className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${centered ? ' modal-dialog-centered' : ''}`}
</form> role="document"
</Modal> >
<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; export default FormModal;
+133 -124
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,140 +87,148 @@ 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)' }}
<IconUpload className="me-2" size={24} /> onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
{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>
</>
}
> >
{/* Description */} <div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="alert alert-info mb-3"> <div className="modal-content" tabIndex={-1}>
<div className="d-flex"> <div className="modal-header">
<IconAlertCircle className="me-2 flex-shrink-0" size={20} /> <h5 className="modal-title d-flex align-items-center">
<div className="text-muted small">{description}</div> <IconUpload className="me-2" size={24} />
</div> {title}
</div> </h5>
<button type="button" className="btn-close" onClick={onClose} />
{/* Drop zone */} </div>
<div
className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`} <div className="modal-body">
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} {/* Description */}
onDragLeave={() => setDragOver(false)} <div className="alert alert-info mb-3">
onDrop={handleDrop} <div className="d-flex">
> <IconAlertCircle className="me-2 flex-shrink-0" size={20} />
<div className="card-body"> <div className="text-muted small">{description}</div>
<div className="d-flex align-items-center justify-content-between"> </div>
<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> </div>
<label className="btn btn-outline-primary mb-0">
<IconUpload size={16} className="me-1" /> {/* Drop zone */}
Выбрать файл <div
<input className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`}
type="file" onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
accept=".txt,.csv,.log" onDragLeave={() => setDragOver(false)}
hidden onDrop={handleDrop}
onChange={async (e) => { >
const f = e.target.files?.[0]; <div className="card-body">
if (!f) return; <div className="d-flex align-items-center justify-content-between">
setFileName(f.name); <div className="text-muted">
const t = await f.text(); {fileName ? (
setText(t); <div className="d-flex align-items-center">
parseText(t); <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 */} {/* Stats */}
<div className="mb-3"> <div className="row g-2">
<textarea <div className="col-md-6">
ref={textAreaRef} <div className="card bg-green-lt">
className="form-control font-monospace" <div className="card-body py-2">
rows={8} <div className="d-flex align-items-center">
value={text} <IconCheck size={20} className="text-green me-2" />
onChange={(e) => handleChange(e.target.value)} <div>
placeholder={placeholder} <div className="text-muted small">Готово к импорту</div>
/> <div className="h3 m-0 text-green">{parsed.items.length}</div>
</div> </div>
</div>
{/* Stats */} </div>
<div className="row g-2"> </div>
<div className="col-md-6"> </div>
<div className="card bg-green-lt"> <div className="col-md-6">
<div className="card-body py-2"> <div className="card bg-red-lt">
<div className="d-flex align-items-center"> <div className="card-body py-2">
<IconCheck size={20} className="text-green me-2" /> <div className="d-flex align-items-center">
<div> <IconX size={20} className="text-red me-2" />
<div className="text-muted small">Готово к импорту</div> <div>
<div className="h3 m-0 text-green">{parsed.items.length}</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>
</div>
</div> {parsed.invalid.length > 0 && (
<div className="col-md-6"> <div className="alert alert-warning mt-3">
<div className="card bg-red-lt"> <div className="text-muted small">
<div className="card-body py-2"> <strong>Некорректные строки (показаны первые 5):</strong>
<div className="d-flex align-items-center"> <ul className="mb-0 mt-1">
<IconX size={20} className="text-red me-2" /> {parsed.invalid.slice(0, 5).map((line, i) => (
<div> <li key={i}><code>{line}</code></li>
<div className="text-muted small">Пропущено (ошибки)</div> ))}
<div className="h3 m-0 text-red">{parsed.invalid.length}</div> </ul>
</div> </div>
</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> </div>
</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>
); );
} }
+82 -73
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,79 +104,87 @@ 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)' }}
<IconSettings className="me-2" size={24} /> onKeyDown={(e) => { if (e.key === 'Escape') 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>
</>
}
> >
{error && <ErrorAlert message={error} onClose={() => setError('')} />} <div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
{success && ( <div className="modal-header">
<div className="alert alert-success alert-dismissible" role="alert"> <h5 className="modal-title d-flex align-items-center">
<div className="d-flex"> <IconSettings className="me-2" size={24} />
<div className="flex-grow-1">{success}</div> Настройки интерфейса
</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> </div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
/>
</div> </div>
)} </div>
</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>
); );
} }
+91 -80
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,87 +118,97 @@ 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)' }}
<IconPlugConnected className="me-2" size={24} /> onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
Логи запуска
{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>
}
> >
{/* Controls */} <div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="d-flex gap-2 mb-3"> <div className="modal-content" tabIndex={-1}>
<button <div className="modal-header">
className="btn btn-outline-secondary btn-sm" <h5 className="modal-title d-flex align-items-center">
onClick={() => setAutoScroll(!autoScroll)} <IconPlugConnected className="me-2" size={24} />
title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'} Логи запуска
> {getStatusBadge()}
{autoScroll ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />} </h5>
<span className="ms-1">Автопрокрутка</span> <button type="button" className="btn-close" onClick={onClose} />
</button> </div>
<button
className="btn btn-outline-primary btn-sm" <div className="modal-body">
onClick={copyLog} {/* Controls */}
title="Скопировать лог" <div className="d-flex gap-2 mb-3">
> <button
<IconCopy size={16} /> className="btn btn-outline-secondary btn-sm"
<span className="ms-1">Копировать</span> onClick={() => setAutoScroll(!autoScroll)}
</button> title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}
<button >
className="btn btn-outline-danger btn-sm" {autoScroll ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
onClick={clearLog} <span className="ms-1">Автопрокрутка</span>
title="Очистить лог" </button>
> <button
<IconTrash size={16} /> className="btn btn-outline-primary btn-sm"
<span className="ms-1">Очистить</span> onClick={copyLog}
</button> title="Скопировать лог"
</div> >
<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 */} {/* Log output */}
<div <div
className="bg-dark text-light p-3 rounded font-monospace" className="bg-dark text-light p-3 rounded font-monospace"
style={{ style={{
maxHeight: '60vh', maxHeight: '60vh',
overflowY: 'auto', overflowY: 'auto',
fontSize: '0.875rem', fontSize: '0.875rem',
border: '1px solid rgba(255,255,255,0.08)' border: '1px solid rgba(255,255,255,0.08)'
}} }}
> >
{rawMessages.length === 0 ? ( {rawMessages.length === 0 ? (
<div className="text-muted">Ожидание сообщений от сервера...</div> <div className="text-muted">Ожидание сообщений от сервера...</div>
) : ( ) : (
rawMessages.map((m, i) => { rawMessages.map((m, i) => {
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text)); const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
return ( return (
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}> <div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
<span className="text-secondary">$</span> {m.text} <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>
); <button className="btn btn-secondary" onClick={onClose}>
}) <IconX size={16} className="me-1" />
)} Закрыть
<div ref={bottomRef} /> </button>
</div>
</div>
</div>
</div> </div>
</Modal> </div>
); );
} }