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

- 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:
jeanotx32
2026-08-01 01:22:45 -04:00
parent 022fbabe5d
commit f37b639226
20 changed files with 1689 additions and 727 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}