feat: enhance VPS management interface with search, filter, and toast notifications
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
- Added a DashboardToolbar component for searching, filtering, and sorting VPS instances. - Implemented a ConfirmDialog component for destructive actions confirmation. - Introduced a Modal component for displaying modals with focus management. - Created a Toast component for displaying notifications with different types (success, error, info). - Refactored VpsCard to utilize new Metric component for displaying CPU and RAM usage. - Improved user experience with local storage for collapsed state in VpsCard. - Added clipboard utility functions for copying text and downloading content. - Enhanced CSS styles for better dark mode support and animations. - Updated various UI controls for consistency and improved accessibility.
This commit is contained in:
131
vps-monitor/frontend/src/components/ui/Modal.jsx
Normal file
131
vps-monitor/frontend/src/components/ui/Modal.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SIZES = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ')
|
||||
|
||||
/**
|
||||
* Modale de base : fermeture par Échap et clic sur le fond, piège à focus,
|
||||
* restitution du focus à la fermeture, verrouillage du défilement, sémantique
|
||||
* ARIA. Tous les modals de l'application passent par ici.
|
||||
*/
|
||||
export default function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
size = 'md',
|
||||
onClose,
|
||||
headerRight,
|
||||
footer,
|
||||
children,
|
||||
bodyClassName = 'overflow-y-auto p-4',
|
||||
panelClassName = '',
|
||||
}) {
|
||||
const panelRef = useRef(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
const titleId = useId()
|
||||
|
||||
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
|
||||
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
|
||||
// au premier champ à chaque rafraîchissement automatique).
|
||||
useEffect(() => { onCloseRef.current = onClose })
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement
|
||||
const panel = panelRef.current
|
||||
const firstFocusable = panel?.querySelector(FOCUSABLE)
|
||||
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
onCloseRef.current?.()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !panel) return
|
||||
|
||||
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
|
||||
if (items.length === 0) return
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = previousOverflow
|
||||
previouslyFocused?.focus?.({ preventScroll: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className={`w-full ${SIZES[size] ?? SIZES.md} flex flex-col max-h-[88vh] bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl outline-none animate-modal-in ${panelClassName}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{icon}
|
||||
<div className="min-w-0">
|
||||
<h2 id={titleId} className="font-semibold text-sm truncate">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-gray-500 truncate mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{headerRight}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Fermer"
|
||||
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* `min-h-0` autorise l'enfant à défiler dans un conteneur flex */}
|
||||
<div className={`flex-1 min-h-0 ${bodyClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-4 py-3 border-t border-gray-800 flex-shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user