feat(web): reusable Modal + ConfirmProvider, replace native confirm()
Modal component: portal, ESC to close, Tab focus-trap, focus-on-open
(prefers [data-modal-autofocus]), focus restore, overlay click, scroll lock.
ConfirmProvider exposes useConfirm(): async confirm({...}) as a drop-in for
window.confirm; Enter confirms, ESC cancels. Task/account deletes now use it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
@@ -254,6 +254,77 @@
|
|||||||
cursor: not-allowed;
|
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 ---------- */
|
/* ---------- buttons ---------- */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
@@ -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<boolean>
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<ConfirmFn | null>(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 <ConfirmProvider>')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [opts, setOpts] = useState<ConfirmOptions | null>(null)
|
||||||
|
const resolver = useRef<(v: boolean) => void>(() => {})
|
||||||
|
|
||||||
|
const confirm = useCallback<ConfirmFn>((o) => {
|
||||||
|
setOpts(o)
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
resolver.current = resolve
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finish = useCallback((result: boolean) => {
|
||||||
|
resolver.current(result)
|
||||||
|
resolver.current = () => {}
|
||||||
|
setOpts(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
<Modal open={opts !== null} title={opts?.title ?? 'Confirm'} onClose={() => finish(false)}>
|
||||||
|
{opts && (
|
||||||
|
<div
|
||||||
|
className="confirm-body"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
finish(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="confirm-message">{opts.message}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => finish(false)}>
|
||||||
|
{opts.cancelLabel ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={opts.danger ? 'btn btn-danger' : 'btn btn-primary'}
|
||||||
|
data-modal-autofocus
|
||||||
|
onClick={() => finish(true)}
|
||||||
|
>
|
||||||
|
{opts.confirmLabel ?? 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLElement>(sel)).filter(
|
||||||
|
(el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable modal shell: portal to <body>, 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<HTMLDivElement>(null)
|
||||||
|
const prevFocus = useRef<HTMLElement | null>(null)
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
prevFocus.current = document.activeElement as HTMLElement | null
|
||||||
|
|
||||||
|
const auto = dialogRef.current?.querySelector<HTMLElement>('[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(
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog" role="dialog" aria-modal="true" aria-label={title} ref={dialogRef} tabIndex={-1}>
|
||||||
|
{title && <div className="modal-title">{title}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
-1
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { ConfirmProvider } from './components/ConfirmProvider'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ConfirmProvider>
|
||||||
|
<App />
|
||||||
|
</ConfirmProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { createAccount, deleteAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
||||||
import { connectTaskWS, type TaskEvent } from '../ws'
|
import { connectTaskWS, type TaskEvent } from '../ws'
|
||||||
import { StatusBadge } from '../components/StatusBadge'
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
|
|
||||||
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
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 [log, setLog] = useState<{ type: string; text: string }[]>([])
|
||||||
const [form, setForm] = useState(emptyAccount)
|
const [form, setForm] = useState(emptyAccount)
|
||||||
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | null>(null)
|
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | null>(null)
|
||||||
|
const confirm = useConfirm()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -111,7 +113,13 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onDeleteAccount(accId: number, login: string) {
|
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')
|
setBusy('delete')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { createTask, deleteTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
|
import { createTask, deleteTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
|
||||||
import { StatusBadge } from '../components/StatusBadge'
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
|
|
||||||
export function Tasks() {
|
export function Tasks() {
|
||||||
const [tasks, setTasks] = useState<Task[] | null>(null)
|
const [tasks, setTasks] = useState<Task[] | null>(null)
|
||||||
@@ -10,6 +11,7 @@ export function Tasks() {
|
|||||||
const [dstId, setDstId] = useState('')
|
const [dstId, setDstId] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
listTasks()
|
listTasks()
|
||||||
@@ -23,7 +25,13 @@ export function Tasks() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function onDeleteTask(id: number, taskName: string) {
|
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)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await deleteTask(id)
|
await deleteTask(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user