ADD now probes both connections, lists folders on each side, and opens a
mapping modal to route source->destination folders (e.g. Спам -> Spam) so we
append into the existing folder instead of creating a duplicate.
- store: SetTaskFolderMapping (+ round-trip test)
- httpapi: POST /tasks/{id}/probe (test both, return folder lists),
PUT /tasks/{id}/folder-mapping
- web: FolderMappingModal (reuses Modal, size=lg), submitAccount probes then
opens the modal; confirm creates the account and saves the task mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
type ModalProps = {
|
|
open: boolean
|
|
title?: string
|
|
onClose: () => void
|
|
children: ReactNode
|
|
size?: 'md' | 'lg'
|
|
}
|
|
|
|
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, size = 'md' }: 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 modal-${size}`} role="dialog" aria-modal="true" aria-label={title} ref={dialogRef} tabIndex={-1}>
|
|
{title && <div className="modal-title">{title}</div>}
|
|
{children}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|