diff --git a/web/src/app.css b/web/src/app.css index 9f7bc10..15d792b 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -254,6 +254,77 @@ cursor: not-allowed; } +/* ---------- modal ---------- */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(4, 7, 5, 0.72); + backdrop-filter: blur(2px); + animation: modal-fade 0.12s ease-out; +} + +.modal-dialog { + width: 100%; + max-width: 440px; + background: var(--bg-panel-raised); + border: 1px solid var(--border-bright); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); + padding: 22px 24px; + outline: none; + animation: modal-rise 0.14s ease-out; +} + +.modal-title { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 14px; +} + +.confirm-message { + margin: 0 0 20px; + color: var(--fg); + font-size: 14px; + line-height: 1.5; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-danger { + border-color: var(--fail-dim); + color: var(--fail); +} + +.btn-danger:hover:not(:disabled) { + background: var(--fail-dim); + color: var(--fail); +} + +@keyframes modal-fade { + from { + opacity: 0; + } +} + +@keyframes modal-rise { + from { + opacity: 0; + transform: translateY(6px); + } +} + /* ---------- buttons ---------- */ .btn { diff --git a/web/src/components/ConfirmProvider.tsx b/web/src/components/ConfirmProvider.tsx new file mode 100644 index 0000000..fa8bf3b --- /dev/null +++ b/web/src/components/ConfirmProvider.tsx @@ -0,0 +1,73 @@ +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react' +import { Modal } from './Modal' + +export type ConfirmOptions = { + title?: string + message: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise + +const ConfirmContext = createContext(null) + +// Drop-in async replacement for window.confirm(): `await confirm({ message })`. +export function useConfirm(): ConfirmFn { + const ctx = useContext(ConfirmContext) + if (!ctx) throw new Error('useConfirm must be used within ') + return ctx +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [opts, setOpts] = useState(null) + const resolver = useRef<(v: boolean) => void>(() => {}) + + const confirm = useCallback((o) => { + setOpts(o) + return new Promise((resolve) => { + resolver.current = resolve + }) + }, []) + + const finish = useCallback((result: boolean) => { + resolver.current(result) + resolver.current = () => {} + setOpts(null) + }, []) + + return ( + + {children} + finish(false)}> + {opts && ( +
{ + if (e.key === 'Enter') { + e.preventDefault() + finish(true) + } + }} + > +

{opts.message}

+
+ + +
+
+ )} +
+
+ ) +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..322d546 --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +type ModalProps = { + open: boolean + title?: string + onClose: () => void + children: ReactNode +} + +function focusable(root: HTMLElement | null): HTMLElement[] { + if (!root) return [] + const sel = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + return Array.from(root.querySelectorAll(sel)).filter( + (el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true', + ) +} + +// Reusable modal shell: portal to , ESC to close, Tab focus-trap, +// focus-on-open (prefers [data-modal-autofocus]), focus restore on close, +// click-on-overlay to close, and body scroll lock while open. +export function Modal({ open, title, onClose, children }: ModalProps) { + const dialogRef = useRef(null) + const prevFocus = useRef(null) + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + + useEffect(() => { + if (!open) return + prevFocus.current = document.activeElement as HTMLElement | null + + const auto = dialogRef.current?.querySelector('[data-modal-autofocus]') + ;(auto ?? focusable(dialogRef.current)[0] ?? dialogRef.current)?.focus() + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault() + onCloseRef.current() + return + } + if (e.key !== 'Tab') return + const items = focusable(dialogRef.current) + if (items.length === 0) { + e.preventDefault() + return + } + const first = items[0] + const last = items[items.length - 1] + const active = document.activeElement as HTMLElement | null + const inside = dialogRef.current?.contains(active) + if (e.shiftKey) { + if (active === first || !inside) { + e.preventDefault() + last.focus() + } + } else if (active === last || !inside) { + e.preventDefault() + first.focus() + } + } + + document.addEventListener('keydown', onKeyDown, true) + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKeyDown, true) + document.body.style.overflow = prevOverflow + prevFocus.current?.focus?.() + } + }, [open]) + + if (!open) return null + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+ {title &&
{title}
} + {children} +
+
, + document.body, + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..129cdbd 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { ConfirmProvider } from './components/ConfirmProvider' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 3e1cc05..4d8532e 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'r import { createAccount, deleteAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' +import { useConfirm } from '../components/ConfirmProvider' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -40,6 +41,7 @@ export function TaskDetail({ id }: { id: number }) { const [log, setLog] = useState<{ type: string; text: string }[]>([]) const [form, setForm] = useState(emptyAccount) const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | null>(null) + const confirm = useConfirm() const [error, setError] = useState(null) const fileInputRef = useRef(null) @@ -111,7 +113,13 @@ export function TaskDetail({ id }: { id: number }) { } async function onDeleteAccount(accId: number, login: string) { - if (!confirm(`Remove account "${login}" from this task?`)) return + const ok = await confirm({ + title: 'Remove account', + message: `Remove account "${login}" from this task?`, + confirmLabel: 'Remove', + danger: true, + }) + if (!ok) return setBusy('delete') setError(null) try { diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index 83f2c7a..8fc127c 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { createTask, deleteTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api' import { StatusBadge } from '../components/StatusBadge' +import { useConfirm } from '../components/ConfirmProvider' export function Tasks() { const [tasks, setTasks] = useState(null) @@ -10,6 +11,7 @@ export function Tasks() { const [dstId, setDstId] = useState('') const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const confirm = useConfirm() function reload() { listTasks() @@ -23,7 +25,13 @@ export function Tasks() { }, []) async function onDeleteTask(id: number, taskName: string) { - if (!confirm(`Delete task "${taskName}" and all its accounts?`)) return + const ok = await confirm({ + title: 'Delete task', + message: `Delete task "${taskName}" and all its accounts?`, + confirmLabel: 'Delete', + danger: true, + }) + if (!ok) return setError(null) try { await deleteTask(id)