Initial commit
This commit is contained in:
163
frontend/src/features/help/components/HelpButton.tsx
Normal file
163
frontend/src/features/help/components/HelpButton.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { HelpCircle, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HelpButtonProps {
|
||||
/**
|
||||
* Contenu de l'aide à afficher
|
||||
*/
|
||||
title: string;
|
||||
content: string | React.ReactNode;
|
||||
/**
|
||||
* Position du tooltip
|
||||
* @default "top"
|
||||
*/
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
/**
|
||||
* Variante du bouton
|
||||
* @default "icon"
|
||||
*/
|
||||
variant?: "icon" | "text" | "inline";
|
||||
/**
|
||||
* Classe CSS personnalisée
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Si vrai, le tooltip est toujours visible
|
||||
* @default false
|
||||
*/
|
||||
alwaysOpen?: boolean;
|
||||
}
|
||||
|
||||
export function HelpButton({
|
||||
title,
|
||||
content,
|
||||
position = "top",
|
||||
variant = "icon",
|
||||
className,
|
||||
alwaysOpen = false,
|
||||
}: HelpButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(alwaysOpen);
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-3",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-3",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-3",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-3",
|
||||
};
|
||||
|
||||
const arrowClasses = {
|
||||
top: "top-full left-1/2 -translate-x-1/2 -mt-1 border-t-slate-900 border-r-transparent border-b-transparent border-l-transparent",
|
||||
bottom: "bottom-full left-1/2 -translate-x-1/2 -mb-1 border-b-slate-900 border-r-transparent border-t-transparent border-l-transparent",
|
||||
left: "left-full top-1/2 -translate-y-1/2 -ml-1 border-l-slate-900 border-t-transparent border-r-transparent border-b-transparent",
|
||||
right: "right-full top-1/2 -translate-y-1/2 -mr-1 border-r-slate-900 border-t-transparent border-l-transparent border-b-transparent",
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
icon: (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-5 h-5 rounded-full transition-all duration-200",
|
||||
"text-slate-400 hover:text-indigo-600 hover:bg-indigo-50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
|
||||
isOpen && "bg-indigo-100 text-indigo-700",
|
||||
className
|
||||
)}
|
||||
aria-label={`Aide: ${title}`}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
),
|
||||
text: (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md transition-all duration-200",
|
||||
"text-xs font-medium text-slate-500 hover:text-indigo-600 hover:bg-indigo-50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
|
||||
isOpen && "bg-indigo-100 text-indigo-700",
|
||||
className
|
||||
)}
|
||||
aria-label={`Aide: ${title}`}
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span>Aide</span>
|
||||
</button>
|
||||
),
|
||||
inline: (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-700 underline underline-dotted underline-offset-2",
|
||||
"transition-all duration-200",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 rounded px-1",
|
||||
className
|
||||
)}
|
||||
aria-label={`Aide: ${title}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
</button>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center">
|
||||
{buttonVariants[variant]}
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 w-80 max-w-sm",
|
||||
positionClasses[position]
|
||||
)}
|
||||
>
|
||||
<div className="relative bg-slate-900 text-slate-100 rounded-lg shadow-xl p-4">
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-0 h-0 border-8",
|
||||
arrowClasses[position]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 text-slate-400 hover:text-white transition-colors"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pr-6">
|
||||
<h4 className="text-sm font-bold text-white mb-2">{title}</h4>
|
||||
<div className="text-xs text-slate-300 leading-relaxed space-y-2">
|
||||
{typeof content === "string" ? (
|
||||
<p>{content}</p>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/features/help/components/HelpModal.tsx
Normal file
108
frontend/src/features/help/components/HelpModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HelpModalProps {
|
||||
/**
|
||||
* Contrôle l'ouverture du modal
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback lors de la fermeture
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Titre du modal
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Contenu du modal
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Classe CSS personnalisée pour le contenu
|
||||
*/
|
||||
contentClassName?: string;
|
||||
/**
|
||||
* Taille du modal
|
||||
* @default "md"
|
||||
*/
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
full: "max-w-6xl",
|
||||
};
|
||||
|
||||
export function HelpModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
children,
|
||||
contentClassName,
|
||||
size = "md",
|
||||
}: HelpModalProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-sm z-50",
|
||||
"transition-opacity duration-200",
|
||||
"data-[state=open]:opacity-100",
|
||||
"data-[state=closed]:opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"w-full",
|
||||
sizeClasses[size],
|
||||
"bg-white rounded-2xl shadow-2xl z-50",
|
||||
"border border-slate-200",
|
||||
"transition-all duration-200",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-left-1/2",
|
||||
"data-[state=closed]:slide-out-to-top-[48%]",
|
||||
"data-[state=open]:slide-in-from-left-1/2",
|
||||
"data-[state=open]:slide-in-from-top-[48%]"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<Dialog.Title className="text-lg font-bold text-slate-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
"text-slate-400 hover:text-slate-700 hover:bg-slate-100",
|
||||
"transition-colors duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("px-6 py-4 max-h-[60vh] overflow-y-auto", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
383
frontend/src/features/help/components/HelpPanel.tsx
Normal file
383
frontend/src/features/help/components/HelpPanel.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
BookOpen,
|
||||
FileText,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
// Types pour la structure de documentation
|
||||
interface DocSection {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: DocSection[];
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// Structure de documentation basée sur les fichiers markdown
|
||||
const DOCUMENTATION_STRUCTURE: DocSection[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
title: "Démarrage Rapide",
|
||||
icon: <BookOpen className="w-5 h-5" />,
|
||||
children: [
|
||||
{
|
||||
id: "import-data",
|
||||
title: "Importer vos données",
|
||||
description: "Formats supportés : CSV, Excel",
|
||||
content: "user-guide#importer-vos-données",
|
||||
},
|
||||
{
|
||||
id: "explore-data",
|
||||
title: "Explorer vos données",
|
||||
description: "Table intelligente et détection d'outliers",
|
||||
content: "user-guide#explorer-vos-données",
|
||||
},
|
||||
{
|
||||
id: "start-analysis",
|
||||
title: "Lancer une analyse",
|
||||
description: "Corrélation et régression",
|
||||
content: "user-guide#lancer-une-analyse",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "correlation",
|
||||
title: "Analyse de Corrélation",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
children: [
|
||||
{
|
||||
id: "correlation-methods",
|
||||
title: "Méthodes de corrélation",
|
||||
description: "Pearson, Spearman, Kendall",
|
||||
content: "correlation-guide#les-trois-méthodes",
|
||||
},
|
||||
{
|
||||
id: "interpret-heatmap",
|
||||
title: "Interpréter la heatmap",
|
||||
description: "Comprendre les couleurs et valeurs",
|
||||
content: "correlation-guide#interpréter-la-matrice",
|
||||
},
|
||||
{
|
||||
id: "multicollinearity",
|
||||
title: "Multicolinéarité",
|
||||
description: "Détecter et éviter les corrélations entre prédicteurs",
|
||||
content: "correlation-guide#multicolinéarité",
|
||||
},
|
||||
{
|
||||
id: "p-values",
|
||||
title: "P-values et significativité",
|
||||
description: "Comprendre la significativité statistique",
|
||||
content: "correlation-guide#p-values-et-significativité",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regression",
|
||||
title: "Régression Statistique",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
children: [
|
||||
{
|
||||
id: "model-types",
|
||||
title: "Types de modèles",
|
||||
description: "Linéaire, logistique, polynomial, exponentielle",
|
||||
content: "regression-guide#types-de-modèles",
|
||||
},
|
||||
{
|
||||
id: "configure-model",
|
||||
title: "Configuration du modèle",
|
||||
description: "Choisir cible et prédicteurs",
|
||||
content: "regression-guide#configuration-du-modèle",
|
||||
},
|
||||
{
|
||||
id: "interpret-results",
|
||||
title: "Interpréter les résultats",
|
||||
description: "R², coefficients, p-values",
|
||||
content: "regression-guide#interprétation-des-résultats",
|
||||
},
|
||||
{
|
||||
id: "model-equations",
|
||||
title: "Équations du modèle",
|
||||
description: "Exporter en Python, Excel, LaTeX",
|
||||
content: "regression-guide#équations-du-modèle",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "outliers",
|
||||
title: "Gestion des Outliers",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
children: [
|
||||
{
|
||||
id: "outlier-types",
|
||||
title: "Types d'outliers",
|
||||
description: "Univariés vs multivariés",
|
||||
content: "outlier-guide#types-doutliers",
|
||||
},
|
||||
{
|
||||
id: "detection-methods",
|
||||
title: "Méthodes de détection",
|
||||
description: "IQR et Isolation Forest",
|
||||
content: "outlier-guide#méthodes-de-détection",
|
||||
},
|
||||
{
|
||||
id: "visual-indicators",
|
||||
title: "Indicateurs visuels",
|
||||
description: "Cercles rouge et violet",
|
||||
content: "outlier-guide#indicateurs-visuels",
|
||||
},
|
||||
{
|
||||
id: "manage-outliers",
|
||||
title: "Gérer les outliers",
|
||||
description: "Processus d'exclusion",
|
||||
content: "outlier-guide#processus-de-gestion",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface HelpPanelProps {
|
||||
/**
|
||||
* Contrôle l'ouverture du panel
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback lors de la fermeture
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function HelpPanel({ open: controlledOpen, onOpenChange }: HelpPanelProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(["getting-started"])
|
||||
);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = (newOpen: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
} else {
|
||||
next.add(sectionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Filtrer les sections basées sur la recherche
|
||||
const filteredStructure = searchQuery
|
||||
? DOCUMENTATION_STRUCTURE.map((section) => ({
|
||||
...section,
|
||||
children: section.children?.filter(
|
||||
(child) =>
|
||||
child.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
child.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
),
|
||||
})).filter((section) => (section.children?.length || 0) > 0)
|
||||
: DOCUMENTATION_STRUCTURE;
|
||||
|
||||
// Ouvrir automatiquement les sections lors de la recherche
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setExpandedSections(new Set(filteredStructure.map((s) => s.id)));
|
||||
}
|
||||
}, [searchQuery, filteredStructure]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
|
||||
"transition-opacity duration-200",
|
||||
open ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed top-0 right-0 bottom-0 w-[480px] max-w-[90vw] bg-white z-50",
|
||||
"shadow-2xl border-l border-slate-200",
|
||||
"transition-transform duration-200 ease-out",
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
|
||||
?
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-bold text-slate-900">
|
||||
Centre d'Aide
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-slate-500">
|
||||
Guides et documentation
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
"text-slate-400 hover:text-slate-700 hover:bg-slate-100",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans les guides..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"w-full pl-10 pr-4 py-2.5 rounded-lg",
|
||||
"bg-slate-50 border border-slate-200",
|
||||
"text-sm text-slate-900 placeholder:text-slate-400",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent",
|
||||
"transition-all duration-150"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-2">
|
||||
{filteredStructure.map((section) => {
|
||||
const isExpanded = expandedSections.has(section.id);
|
||||
const hasChildren = section.children && section.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={section.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
disabled={!hasChildren}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3",
|
||||
"bg-slate-50 hover:bg-slate-100",
|
||||
"transition-colors duration-150",
|
||||
!hasChildren && "cursor-default"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
"bg-indigo-100 text-indigo-600"
|
||||
)}>
|
||||
{section.icon}
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-slate-900">
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-slate-400 transition-transform duration-200",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Section Children */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="border-t border-slate-200 bg-white divide-y divide-slate-100">
|
||||
{section.children!.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={`/docs/${child.content}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-start gap-3 px-4 py-3",
|
||||
"hover:bg-indigo-50/50",
|
||||
"transition-colors duration-150",
|
||||
"group"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-900 group-hover:text-indigo-700 transition-colors">
|
||||
{child.title}
|
||||
</span>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-slate-400 group-hover:text-indigo-600 transition-colors shrink-0" />
|
||||
</div>
|
||||
{child.description && (
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
{child.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredStructure.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Aucun résultat pour "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4">
|
||||
<a
|
||||
href="/docs/readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 w-full",
|
||||
"px-4 py-2.5 rounded-lg",
|
||||
"bg-gradient-to-r from-indigo-600 to-purple-600",
|
||||
"text-white text-sm font-semibold",
|
||||
"hover:from-indigo-700 hover:to-purple-700",
|
||||
"transition-all duration-150",
|
||||
"shadow-md hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Voir toute la documentation</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/help/components/HelpTooltip.tsx
Normal file
120
frontend/src/features/help/components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HelpTooltipProps {
|
||||
/**
|
||||
* Contenu du tooltip
|
||||
*/
|
||||
content: string | React.ReactNode;
|
||||
/**
|
||||
* Position du tooltip
|
||||
* @default "top"
|
||||
*/
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
/**
|
||||
* Largeur maximale du tooltip
|
||||
* @default "250px"
|
||||
*/
|
||||
maxWidth?: string;
|
||||
/**
|
||||
* Classe CSS personnalisée pour le wrapper
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Si vrai, affiche une icône d'information
|
||||
* @default true
|
||||
*/
|
||||
showIcon?: boolean;
|
||||
/**
|
||||
* Contenu personnalisé pour déclencher le tooltip
|
||||
*/
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function HelpTooltip({
|
||||
content,
|
||||
position = "top",
|
||||
maxWidth = "250px",
|
||||
className,
|
||||
showIcon = true,
|
||||
trigger,
|
||||
}: HelpTooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2 pb-1",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2 pt-1",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2 pr-1",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2 pl-1",
|
||||
};
|
||||
|
||||
const arrowClasses = {
|
||||
top: "top-full left-1/2 -translate-x-1/2 border-t-slate-800 border-r-transparent border-b-transparent border-l-transparent",
|
||||
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-slate-800 border-r-transparent border-t-transparent border-l-transparent",
|
||||
left: "left-full top-1/2 -translate-y-1/2 border-l-slate-800 border-t-transparent border-r-transparent border-b-transparent",
|
||||
right: "right-full top-1/2 -translate-y-1/2 border-r-slate-800 border-t-transparent border-l-transparent border-b-transparent",
|
||||
};
|
||||
|
||||
const defaultTrigger = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-4 h-4 rounded-full",
|
||||
"text-slate-400 hover:text-indigo-600 hover:bg-indigo-50",
|
||||
"transition-colors duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
aria-label="Information"
|
||||
>
|
||||
<Info className="w-3 h-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex">
|
||||
<div
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{trigger || defaultTrigger}
|
||||
</div>
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 pointer-events-none",
|
||||
positionClasses[position]
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
role="tooltip"
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-0 h-0 border-4",
|
||||
arrowClasses[position]
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative bg-slate-800 text-slate-100 text-xs rounded-md shadow-lg px-3 py-2 leading-relaxed">
|
||||
{typeof content === "string" ? (
|
||||
<p>{content}</p>
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
frontend/src/features/help/components/TourGuide.tsx
Normal file
376
frontend/src/features/help/components/TourGuide.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { X, ChevronLeft, ChevronRight, Check } from "lucide-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TourStep {
|
||||
/**
|
||||
* Identifiant unique de l'étape
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Titre de l'étape
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Contenu de l'étape
|
||||
*/
|
||||
content: string | React.ReactNode;
|
||||
/**
|
||||
* Sélecteur CSS de l'élément à highlighter
|
||||
* @example "#file-uploader", ".tab-button", "[data-tab='correlation']"
|
||||
*/
|
||||
target?: string;
|
||||
/**
|
||||
* Position du tooltip par rapport à la cible
|
||||
* @default "bottom"
|
||||
*/
|
||||
position?: "top" | "bottom" | "left" | "right" | "center";
|
||||
/**
|
||||
* Image optionnelle pour illustrer l'étape
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* Action optionnelle à exécuter quand l'étape est affichée
|
||||
*/
|
||||
onEnter?: () => void;
|
||||
/**
|
||||
* Action optionnelle à exécuter quand l'étape est quittée
|
||||
*/
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
interface TourGuideProps {
|
||||
/**
|
||||
* Étapes du tour
|
||||
*/
|
||||
steps: TourStep[];
|
||||
/**
|
||||
* Contrôle l'ouverture du tour
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback lors de la fermeture
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Callback à la fin du tour
|
||||
*/
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* Callback quand une étape est_skippée
|
||||
*/
|
||||
onSkip?: () => void;
|
||||
/**
|
||||
* Texte du bouton de fin
|
||||
* @default "Compris !"
|
||||
*/
|
||||
completeButtonText?: string;
|
||||
/**
|
||||
* Texte du bouton pour passer
|
||||
* @default "Passer"
|
||||
*/
|
||||
skipButtonText?: string;
|
||||
/**
|
||||
* Si true, affiche une barre de progression
|
||||
* @default true
|
||||
*/
|
||||
showProgress?: boolean;
|
||||
/**
|
||||
* Si true, le tour ne peut pas être fermé en cliquant en dehors
|
||||
* @default false
|
||||
*/
|
||||
closeOnOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
export function TourGuide({
|
||||
steps,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
onSkip,
|
||||
completeButtonText = "Compris !",
|
||||
skipButtonText = "Passer",
|
||||
showProgress = true,
|
||||
closeOnOutsideClick = false,
|
||||
}: TourGuideProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
|
||||
const setOpen = (newOpen: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
// Gérer l'entrée dans une étape
|
||||
useEffect(() => {
|
||||
if (open && currentStep) {
|
||||
currentStep.onEnter?.();
|
||||
setIsHighlighted(true);
|
||||
|
||||
// Scroll vers l'élément cible si nécessaire
|
||||
if (currentStep.target) {
|
||||
const element = document.querySelector(currentStep.target);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentStep) {
|
||||
currentStep.onExit?.();
|
||||
}
|
||||
setIsHighlighted(false);
|
||||
};
|
||||
}, [currentStepIndex, open, currentStep]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
onComplete?.();
|
||||
setOpen(false);
|
||||
setCurrentStepIndex(0);
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [isLastStep, onComplete, setOpen]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStepIndex((prev) => prev - 1);
|
||||
}
|
||||
}, [isFirstStep]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onSkip?.();
|
||||
setOpen(false);
|
||||
setCurrentStepIndex(0);
|
||||
}, [onSkip, setOpen]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (newOpen === false && !closeOnOutsideClick) {
|
||||
// Empêcher la fermeture si closeOnOutsideClick est false
|
||||
return;
|
||||
}
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setCurrentStepIndex(0);
|
||||
}
|
||||
},
|
||||
[setOpen, closeOnOutsideClick]
|
||||
);
|
||||
|
||||
// Calculer la position du tooltip
|
||||
const getTooltipPosition = () => {
|
||||
if (!currentStep?.target) return "center";
|
||||
|
||||
const element = document.querySelector(currentStep.target);
|
||||
if (!element) return "center";
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Déterminer la meilleure position automatiquement si non spécifiée
|
||||
if (!currentStep.position || currentStep.position === "center") {
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
if (centerY < viewportHeight * 0.3) return "bottom";
|
||||
if (centerY > viewportHeight * 0.7) return "top";
|
||||
if (centerX < viewportWidth * 0.3) return "right";
|
||||
if (centerX > viewportWidth * 0.7) return "left";
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
return currentStep.position;
|
||||
};
|
||||
|
||||
const position = getTooltipPosition();
|
||||
|
||||
if (!open || !currentStep) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay avec spotlight */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[60] bg-black/60 transition-opacity duration-300",
|
||||
isHighlighted && "opacity-100"
|
||||
)}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Spotlight sur l'élément cible */}
|
||||
{currentStep.target && isHighlighted && (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-[61] pointer-events-none transition-all duration-300",
|
||||
"border-4 border-indigo-500 rounded-lg shadow-[0_0_0_9999px_rgba(0,0,0,0.6)]"
|
||||
)}
|
||||
style={{
|
||||
...((() => {
|
||||
const element = document.querySelector(currentStep.target!);
|
||||
if (!element) return {};
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top - 4,
|
||||
left: rect.left - 4,
|
||||
width: rect.width + 8,
|
||||
height: rect.height + 8,
|
||||
};
|
||||
})()),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip du tour */}
|
||||
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed z-[70] w-[400px] max-w-[90vw]",
|
||||
"bg-white rounded-2xl shadow-2xl border border-slate-200",
|
||||
"transition-all duration-300",
|
||||
position === "center" && "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
position === "top" && "left-1/2 -translate-x-1/2 bottom-8",
|
||||
position === "bottom" && "left-1/2 -translate-x-1/2 top-8",
|
||||
position === "left" && "right-8 top-1/2 -translate-y-1/2",
|
||||
position === "right" && "left-8 top-1/2 -translate-y-1/2"
|
||||
)}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (!closeOnOutsideClick) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!closeOnOutsideClick) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Dialog.Title className="text-lg font-bold text-slate-900">
|
||||
{currentStep.title}
|
||||
</Dialog.Title>
|
||||
<button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className={cn(
|
||||
"shrink-0 w-6 h-6 rounded flex items-center justify-center",
|
||||
"text-slate-400 hover:text-slate-600 hover:bg-slate-100",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{showProgress && steps.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 mb-2">
|
||||
<span>Étape {currentStepIndex + 1} sur {steps.length}</span>
|
||||
<span>{Math.round(((currentStepIndex + 1) / steps.length) * 100)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 to-purple-600 transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${((currentStepIndex + 1) / steps.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-4">
|
||||
{currentStep.image && (
|
||||
<div className="mb-4 rounded-lg overflow-hidden border border-slate-200">
|
||||
<img
|
||||
src={currentStep.image}
|
||||
alt={currentStep.title}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-slate-600 leading-relaxed space-y-2">
|
||||
{typeof currentStep.content === "string" ? (
|
||||
<p>{currentStep.content}</p>
|
||||
) : (
|
||||
currentStep.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-slate-50 border-t border-slate-200 rounded-b-2xl">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
{skipButtonText}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFirstStep && (
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-lg",
|
||||
"text-sm font-medium text-slate-700",
|
||||
"hover:bg-slate-200",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Précédent
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-lg",
|
||||
"text-sm font-semibold text-white",
|
||||
"bg-gradient-to-r from-indigo-600 to-purple-600",
|
||||
"hover:from-indigo-700 hover:to-purple-700",
|
||||
"shadow-md hover:shadow-lg",
|
||||
"transition-all duration-150"
|
||||
)}
|
||||
>
|
||||
{isLastStep ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{completeButtonText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Suivant
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
frontend/src/features/help/components/WelcomeTour.tsx
Normal file
72
frontend/src/features/help/components/WelcomeTour.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { TourGuide } from "./TourGuide";
|
||||
import { useHelpStore } from "../lib/help-store";
|
||||
import { WELCOME_TOUR_STEPS } from "../lib/tours";
|
||||
|
||||
export function WelcomeTour() {
|
||||
const { hasSeenWelcomeTour, autoShowTours, markTourAsSeen } = useHelpStore();
|
||||
|
||||
// Ne pas afficher si déjà vu ou si les tours sont désactivés
|
||||
if (hasSeenWelcomeTour || !autoShowTours) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
markTourAsSeen("welcome");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
markTourAsSeen("welcome");
|
||||
};
|
||||
|
||||
return (
|
||||
<TourGuide
|
||||
steps={WELCOME_TOUR_STEPS}
|
||||
open={!hasSeenWelcomeTour && autoShowTours}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markTourAsSeen("welcome");
|
||||
}
|
||||
}}
|
||||
onComplete={handleComplete}
|
||||
onSkip={handleSkip}
|
||||
closeOnOutsideClick={false}
|
||||
showProgress={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CorrelationTour() {
|
||||
const { hasSeenCorrelationTour, autoShowTours, markTourAsSeen } = useHelpStore();
|
||||
|
||||
if (hasSeenCorrelationTour || !autoShowTours) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ce tour sera déclenché manuellement depuis l'onglet corrélation
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RegressionTour() {
|
||||
const { hasSeenRegressionTour, autoShowTours, markTourAsSeen } = useHelpStore();
|
||||
|
||||
if (hasSeenRegressionTour || !autoShowTours) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ce tour sera déclenché manuellement depuis la config de régression
|
||||
return null;
|
||||
}
|
||||
|
||||
export function OutlierTour() {
|
||||
const { hasSeenOutlierTour, autoShowTours, markTourAsSeen } = useHelpStore();
|
||||
|
||||
if (hasSeenOutlierTour || !autoShowTours) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ce tour sera déclenché manuellement depuis la grille
|
||||
return null;
|
||||
}
|
||||
11
frontend/src/features/help/index.ts
Normal file
11
frontend/src/features/help/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Composants
|
||||
export { HelpButton } from "./components/HelpButton";
|
||||
export { HelpTooltip } from "./components/HelpTooltip";
|
||||
export { HelpPanel } from "./components/HelpPanel";
|
||||
export { HelpModal } from "./components/HelpModal";
|
||||
export { TourGuide } from "./components/TourGuide";
|
||||
export type { TourStep } from "./components/TourGuide";
|
||||
export { WelcomeTour } from "./components/WelcomeTour";
|
||||
|
||||
// Store
|
||||
export { useHelpStore } from "./lib/help-store";
|
||||
Reference in New Issue
Block a user