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:
41
vps-monitor/frontend/src/components/ui/ConfirmDialog.jsx
Normal file
41
vps-monitor/frontend/src/components/ui/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import Modal from './Modal'
|
||||
import { Button } from './controls'
|
||||
|
||||
/** Confirmation destructive — remplace `window.confirm`. */
|
||||
export default function ConfirmDialog({
|
||||
title = 'Confirmer',
|
||||
message,
|
||||
confirmLabel = 'Confirmer',
|
||||
cancelLabel = 'Annuler',
|
||||
danger = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
bodyClassName="overflow-y-auto px-5 py-5"
|
||||
icon={
|
||||
<div className={`p-2 rounded-xl flex-shrink-0 ${danger ? 'bg-red-500/15' : 'bg-indigo-500/15'}`}>
|
||||
<AlertTriangle size={16} className={danger ? 'text-red-400' : 'text-indigo-400'} />
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={onCancel} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading} autoFocus>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="text-sm text-gray-400 leading-relaxed">{message}</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
83
vps-monitor/frontend/src/components/ui/Toast.jsx
Normal file
83
vps-monitor/frontend/src/components/ui/Toast.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
|
||||
|
||||
const ToastContext = createContext(null)
|
||||
|
||||
const TYPES = {
|
||||
success: { Icon: CheckCircle2, panel: 'border-emerald-800/60 bg-emerald-950/90', icon: 'text-emerald-400' },
|
||||
error: { Icon: AlertTriangle, panel: 'border-red-800/60 bg-red-950/90', icon: 'text-red-400' },
|
||||
info: { Icon: Info, panel: 'border-gray-700 bg-gray-900/95', icon: 'text-indigo-400' },
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast doit être utilisé à l\'intérieur d\'un <ToastProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const nextId = useRef(0)
|
||||
const timers = useRef(new Map())
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
const timer = timers.current.get(id)
|
||||
if (timer) { clearTimeout(timer); timers.current.delete(id) }
|
||||
setToasts(list => list.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const push = useCallback((message, { type = 'info', duration = 4500 } = {}) => {
|
||||
const id = ++nextId.current
|
||||
setToasts(list => [...list.slice(-(MAX_VISIBLE - 1)), { id, message, type }])
|
||||
if (duration > 0) timers.current.set(id, setTimeout(() => dismiss(id), duration))
|
||||
return id
|
||||
}, [dismiss])
|
||||
|
||||
// Nettoyage des minuteries au démontage
|
||||
useEffect(() => {
|
||||
const pending = timers.current
|
||||
return () => { pending.forEach(clearTimeout); pending.clear() }
|
||||
}, [])
|
||||
|
||||
const api = useMemo(() => ({
|
||||
push,
|
||||
dismiss,
|
||||
success: (message, opts) => push(message, { type: 'success', ...opts }),
|
||||
error: (message, opts) => push(message, { type: 'error', duration: 8000, ...opts }),
|
||||
info: (message, opts) => push(message, { type: 'info', ...opts }),
|
||||
}), [push, dismiss])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-[min(24rem,calc(100vw-2rem))] pointer-events-none"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map(({ id, message, type }) => {
|
||||
const { Icon, panel, icon } = TYPES[type] ?? TYPES.info
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`pointer-events-auto flex items-start gap-2.5 rounded-xl border px-3 py-2.5 shadow-xl backdrop-blur-sm animate-toast-in ${panel}`}
|
||||
>
|
||||
<Icon size={15} className={`flex-shrink-0 mt-0.5 ${icon}`} />
|
||||
<p className="flex-1 text-xs text-gray-200 leading-relaxed break-words">{message}</p>
|
||||
<button
|
||||
onClick={() => dismiss(id)}
|
||||
aria-label="Fermer la notification"
|
||||
className="flex-shrink-0 p-0.5 rounded text-gray-500 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
132
vps-monitor/frontend/src/components/ui/controls.jsx
Normal file
132
vps-monitor/frontend/src/components/ui/controls.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
|
||||
/** Classe commune à tous les champs de saisie. */
|
||||
export const inputClass =
|
||||
'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm placeholder-gray-600 ' +
|
||||
'focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50'
|
||||
|
||||
const VARIANTS = {
|
||||
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white',
|
||||
secondary: 'bg-gray-800 hover:bg-gray-700 text-gray-200',
|
||||
outline: 'border border-gray-700 hover:bg-gray-800 text-gray-200',
|
||||
ghost: 'hover:bg-gray-800 text-gray-400 hover:text-gray-200',
|
||||
danger: 'bg-red-600 hover:bg-red-500 text-white',
|
||||
subtle: 'bg-red-950/50 hover:bg-red-900/60 text-red-400 border border-red-800/50',
|
||||
}
|
||||
|
||||
const BUTTON_SIZES = {
|
||||
sm: 'px-2.5 py-1 text-xs gap-1.5',
|
||||
md: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
lg: 'px-4 py-2 text-sm gap-2',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center rounded-lg font-medium transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${VARIANTS[variant] ?? VARIANTS.secondary} ${BUTTON_SIZES[size] ?? BUTTON_SIZES.md} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading
|
||||
? <Loader2 size={14} className="animate-spin flex-shrink-0" />
|
||||
: Icon && <Icon size={14} className="flex-shrink-0" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_TONES = {
|
||||
default: 'text-gray-500 hover:text-gray-200 hover:bg-gray-800',
|
||||
accent: 'text-gray-500 hover:text-indigo-400 hover:bg-gray-800',
|
||||
danger: 'text-gray-500 hover:text-red-400 hover:bg-red-500/20',
|
||||
success: 'text-emerald-400 hover:bg-gray-800',
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton icône. `label` alimente à la fois `title` et `aria-label` : sans lui,
|
||||
* ces boutons sont muets pour un lecteur d'écran.
|
||||
*/
|
||||
export function IconButton({ icon: Icon, label, tone = 'default', size = 14, className = '', ...props }) {
|
||||
return (
|
||||
<button
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`p-1.5 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${ICON_TONES[tone] ?? ICON_TONES.default} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={size} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Champ mot de passe avec bascule d'affichage. */
|
||||
export function PasswordInput({ className = '', ...props }) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={visible ? 'text' : 'password'}
|
||||
className={`${inputClass} pr-10 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(v => !v)}
|
||||
aria-label={visible ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
title={visible ? 'Masquer' : 'Afficher'}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Barre de progression (CPU, RAM…). */
|
||||
export function ProgressBar({ value, barClass = 'bg-indigo-500', className = '' }) {
|
||||
const pct = Math.max(0, Math.min(100, value ?? 0))
|
||||
return (
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gray-800 overflow-hidden ${className}`}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className={`h-full rounded-full transition-[width] duration-500 ${barClass}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skeleton({ className = '' }) {
|
||||
return <div className={`skeleton rounded-lg ${className}`} />
|
||||
}
|
||||
|
||||
/** Bloc vide illustré (aucun VPS, aucun résultat…). */
|
||||
export function EmptyState({ icon: Icon, title, description, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-20 px-4">
|
||||
{Icon && (
|
||||
<div className="p-3 rounded-2xl bg-gray-900 border border-gray-800 mb-4">
|
||||
<Icon size={24} className="text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-base font-medium text-gray-300">{title}</p>
|
||||
{description && <p className="text-sm text-gray-500 mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user