mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 23:43:22 +00:00
705 lines
22 KiB
JavaScript
705 lines
22 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import InputTextIcon from './InputTextIcon';
|
|
import FormRenderer from './FormRenderer';
|
|
import AddFieldModal from './AddFieldModal';
|
|
import FieldTypeSelector from './FieldTypeSelector';
|
|
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
import {
|
|
Edit2,
|
|
Trash2,
|
|
PlusCircle,
|
|
Download,
|
|
Upload,
|
|
GripVertical,
|
|
TextCursorInput,
|
|
AtSign,
|
|
Calendar,
|
|
ChevronDown,
|
|
Type,
|
|
AlignLeft,
|
|
Save,
|
|
ChevronUp,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
Heading4,
|
|
Heading5,
|
|
Heading6,
|
|
Code,
|
|
Eye,
|
|
EyeOff,
|
|
Phone,
|
|
Radio,
|
|
ToggleLeft,
|
|
CheckSquare,
|
|
FileUp,
|
|
PenTool,
|
|
} from 'lucide-react';
|
|
|
|
const FIELD_TYPES_ICON = {
|
|
text: { icon: TextCursorInput },
|
|
email: { icon: AtSign },
|
|
phone: { icon: Phone },
|
|
date: { icon: Calendar },
|
|
select: { icon: ChevronDown },
|
|
radio: { icon: Radio },
|
|
checkbox: { icon: CheckSquare },
|
|
toggle: { icon: ToggleLeft },
|
|
file: { icon: FileUp },
|
|
signature: { icon: PenTool },
|
|
textarea: { icon: Type },
|
|
paragraph: { icon: AlignLeft },
|
|
heading1: { icon: Heading1 },
|
|
heading2: { icon: Heading2 },
|
|
heading3: { icon: Heading3 },
|
|
heading4: { icon: Heading4 },
|
|
heading5: { icon: Heading5 },
|
|
heading6: { icon: Heading6 },
|
|
};
|
|
|
|
// Type d'item pour le drag and drop
|
|
const ItemTypes = {
|
|
FIELD: 'field',
|
|
};
|
|
|
|
// Composant pour un champ draggable
|
|
const DraggableFieldItem = ({
|
|
field,
|
|
index,
|
|
moveField,
|
|
editField,
|
|
deleteField,
|
|
}) => {
|
|
const ref = React.useRef(null);
|
|
|
|
// Configuration du drag (ce qu'on peut déplacer)
|
|
const [{ isDragging }, drag] = useDrag({
|
|
type: ItemTypes.FIELD,
|
|
item: { index },
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
});
|
|
|
|
// Configuration du drop (où on peut déposer)
|
|
const [, drop] = useDrop({
|
|
accept: ItemTypes.FIELD,
|
|
hover: (item, monitor) => {
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
const dragIndex = item.index;
|
|
const hoverIndex = index;
|
|
|
|
// Ne rien faire si on survole le même élément
|
|
if (dragIndex === hoverIndex) {
|
|
return;
|
|
}
|
|
|
|
// Déterminer la position de la souris par rapport à l'élément survolé
|
|
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
|
const hoverMiddleY =
|
|
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
|
const clientOffset = monitor.getClientOffset();
|
|
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
|
|
|
// Ne pas remplacer si on n'a pas dépassé la moitié de l'élément
|
|
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
|
return;
|
|
}
|
|
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
|
return;
|
|
}
|
|
|
|
// Effectuer le déplacement
|
|
moveField(dragIndex, hoverIndex);
|
|
|
|
// Mettre à jour l'index de l'élément déplacé
|
|
item.index = hoverIndex;
|
|
},
|
|
});
|
|
|
|
// Combiner drag et drop sur le même élément de référence
|
|
drag(drop(ref));
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={`flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-200 ${
|
|
isDragging ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
|
<GripVertical size={18} />
|
|
</div>
|
|
{FIELD_TYPES_ICON[field.type] &&
|
|
React.createElement(FIELD_TYPES_ICON[field.type].icon, {
|
|
size: 18,
|
|
className: 'text-gray-600',
|
|
})}
|
|
<span className="font-medium">
|
|
{field.type === 'paragraph'
|
|
? 'Paragraphe'
|
|
: field.type.startsWith('heading')
|
|
? `Titre ${field.type.replace('heading', '')}`
|
|
: field.label}
|
|
</span>
|
|
<span className="text-sm text-gray-500">
|
|
({field.type}){field.required && ' *'}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => editField(index)}
|
|
className="p-1 text-blue-500 hover:text-blue-700"
|
|
title="Modifier"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => deleteField(index)}
|
|
className="p-1 text-red-500 hover:text-red-700"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function FormTemplateBuilder({
|
|
onSave,
|
|
initialData,
|
|
groups,
|
|
isEditing,
|
|
}) {
|
|
const [formConfig, setFormConfig] = useState({
|
|
id: initialData?.id || 0,
|
|
title: initialData?.name || 'Nouveau formulaire',
|
|
submitLabel: 'Envoyer',
|
|
fields: initialData?.formMasterData?.fields || [],
|
|
});
|
|
|
|
const [selectedGroups, setSelectedGroups] = useState(
|
|
initialData?.groups?.map((g) => g.id) || []
|
|
);
|
|
|
|
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
|
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
|
|
const [selectedFieldType, setSelectedFieldType] = useState(null);
|
|
const [editingIndex, setEditingIndex] = useState(-1);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
|
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
const [showJsonSection, setShowJsonSection] = useState(false);
|
|
|
|
const { reset: resetField } = useForm();
|
|
|
|
// Initialiser les données du formulaire quand initialData change
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
setFormConfig({
|
|
id: initialData.id || 0,
|
|
title: initialData.name || 'Nouveau formulaire',
|
|
submitLabel: 'Envoyer',
|
|
fields: initialData.formMasterData?.fields || [],
|
|
});
|
|
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
|
}
|
|
}, [initialData]);
|
|
|
|
// Gérer l'affichage du bouton de défilement
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
// Afficher le bouton quand on descend d'au moins 300px
|
|
setShowScrollButton(window.scrollY > 300);
|
|
};
|
|
|
|
// Ajouter l'écouteur d'événement
|
|
window.addEventListener('scroll', handleScroll);
|
|
|
|
// Nettoyage de l'écouteur lors du démontage du composant
|
|
return () => {
|
|
window.removeEventListener('scroll', handleScroll);
|
|
};
|
|
}, []);
|
|
|
|
// Fonction pour remonter en haut de la page
|
|
const scrollToTop = () => {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth',
|
|
});
|
|
};
|
|
|
|
// Générer un ID unique pour les champs
|
|
const generateFieldId = (label) => {
|
|
return label
|
|
.toLowerCase()
|
|
.replace(/[àáâãäå]/g, 'a')
|
|
.replace(/[èéêë]/g, 'e')
|
|
.replace(/[ìíîï]/g, 'i')
|
|
.replace(/[òóôõö]/g, 'o')
|
|
.replace(/[ùúûü]/g, 'u')
|
|
.replace(/[ç]/g, 'c')
|
|
.replace(/[^a-z0-9]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_|_$/g, '');
|
|
};
|
|
|
|
// Ajouter ou modifier un champ
|
|
const handleFieldSubmit = (data, currentField, editIndex) => {
|
|
const isHeadingType = data.type.startsWith('heading');
|
|
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
|
|
|
const fieldData = {
|
|
...data,
|
|
id: isContentTypeOnly
|
|
? undefined
|
|
: generateFieldId(data.label || 'field'),
|
|
options: ['select', 'radio'].includes(data.type)
|
|
? Array.isArray(currentField.options)
|
|
? currentField.options
|
|
: []
|
|
: undefined,
|
|
icon: data.icon || currentField.icon || undefined,
|
|
placeholder: data.placeholder || undefined,
|
|
text: isContentTypeOnly ? data.text : undefined,
|
|
checked: ['checkbox', 'toggle'].includes(data.type)
|
|
? currentField.checked
|
|
: undefined,
|
|
horizontal:
|
|
data.type === 'checkbox' ? currentField.horizontal : undefined,
|
|
acceptTypes: data.type === 'file' ? currentField.acceptTypes : undefined,
|
|
maxSize: data.type === 'file' ? currentField.maxSize : undefined,
|
|
validation: ['phone', 'email', 'text'].includes(data.type)
|
|
? currentField.validation
|
|
: undefined,
|
|
};
|
|
|
|
// Nettoyer les propriétés undefined
|
|
Object.keys(fieldData).forEach((key) => {
|
|
if (fieldData[key] === undefined || fieldData[key] === '') {
|
|
delete fieldData[key];
|
|
}
|
|
});
|
|
|
|
const newFields = [...formConfig.fields];
|
|
|
|
if (editIndex >= 0) {
|
|
newFields[editIndex] = fieldData;
|
|
} else {
|
|
newFields.push(fieldData);
|
|
}
|
|
|
|
setFormConfig({ ...formConfig, fields: newFields });
|
|
setEditingIndex(-1);
|
|
}; // Modifier un champ existant
|
|
const editField = (index) => {
|
|
setEditingIndex(index);
|
|
setShowAddFieldModal(true);
|
|
};
|
|
|
|
// Supprimer un champ
|
|
const deleteField = (index) => {
|
|
const newFields = formConfig.fields.filter((_, i) => i !== index);
|
|
setFormConfig({ ...formConfig, fields: newFields });
|
|
};
|
|
|
|
// Déplacer un champ
|
|
const moveField = (dragIndex, hoverIndex) => {
|
|
const newFields = [...formConfig.fields];
|
|
const draggedField = newFields[dragIndex];
|
|
|
|
// Supprimer l'élément déplacé
|
|
newFields.splice(dragIndex, 1);
|
|
|
|
// Insérer l'élément à sa nouvelle position
|
|
newFields.splice(hoverIndex, 0, draggedField);
|
|
|
|
setFormConfig({ ...formConfig, fields: newFields });
|
|
};
|
|
|
|
// Exporter le JSON
|
|
const exportJson = () => {
|
|
const jsonString = JSON.stringify(formConfig, null, 2);
|
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `formulaire_${formConfig.title.replace(/\s+/g, '_').toLowerCase()}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Importer un JSON
|
|
const importJson = (event) => {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const imported = JSON.parse(e.target.result);
|
|
setFormConfig(imported);
|
|
} catch (error) {
|
|
alert('Erreur lors de l'importation du fichier JSON');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
|
|
// Sauvegarder le formulaire (pour le backend)
|
|
const saveFormTemplate = async () => {
|
|
// Validation basique
|
|
if (!formConfig.title.trim()) {
|
|
setSaveMessage({
|
|
type: 'error',
|
|
text: 'Le titre du formulaire est requis',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formConfig.fields.length === 0) {
|
|
setSaveMessage({
|
|
type: 'error',
|
|
text: 'Ajoutez au moins un champ au formulaire',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (selectedGroups.length === 0) {
|
|
setSaveMessage({
|
|
type: 'error',
|
|
text: "Sélectionnez au moins un groupe d'inscription",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setSaveMessage({ type: '', text: '' });
|
|
|
|
try {
|
|
const dataToSave = {
|
|
name: formConfig.title,
|
|
group_ids: selectedGroups,
|
|
formMasterData: formConfig,
|
|
};
|
|
|
|
if (isEditing && initialData) {
|
|
dataToSave.id = initialData.id;
|
|
}
|
|
|
|
if (onSave) {
|
|
onSave(dataToSave);
|
|
}
|
|
|
|
setSaveMessage({
|
|
type: 'success',
|
|
text: 'Formulaire enregistré avec succès',
|
|
});
|
|
} catch (error) {
|
|
setSaveMessage({
|
|
type: 'error',
|
|
text:
|
|
error.message || "Une erreur est survenue lors de l'enregistrement",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Fonction pour gérer la sélection d'un type de champ
|
|
const handleFieldTypeSelect = (fieldType) => {
|
|
setSelectedFieldType(fieldType);
|
|
setShowFieldTypeSelector(false);
|
|
setShowAddFieldModal(true);
|
|
};
|
|
|
|
return (
|
|
<DndProvider backend={HTML5Backend}>
|
|
<div className="max-w-6xl mx-auto p-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
{/* Panel de configuration */}
|
|
<div
|
|
className={
|
|
showJsonSection
|
|
? 'lg:col-span-3 space-y-6'
|
|
: 'lg:col-span-5 space-y-6'
|
|
}
|
|
>
|
|
<div className="bg-white p-6 rounded-lg shadow">
|
|
<h2 className="text-xl font-bold mb-6">
|
|
Configuration du formulaire
|
|
</h2>
|
|
|
|
{/* Configuration générale */}
|
|
<div className="space-y-4 mb-6">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold mr-4">
|
|
Paramètres généraux
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowJsonSection(!showJsonSection)}
|
|
className="px-4 py-2 rounded-md inline-flex items-center gap-2 bg-gray-500 hover:bg-gray-600 text-white"
|
|
title={
|
|
showJsonSection ? 'Masquer le JSON' : 'Afficher le JSON'
|
|
}
|
|
>
|
|
{showJsonSection ? (
|
|
<EyeOff size={18} />
|
|
) : (
|
|
<Eye size={18} />
|
|
)}
|
|
<span>
|
|
{showJsonSection ? 'Masquer JSON' : 'Afficher JSON'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={saveFormTemplate}
|
|
disabled={saving}
|
|
className={`px-4 py-2 rounded-md inline-flex items-center gap-2 ${
|
|
saving
|
|
? 'bg-gray-400 cursor-not-allowed'
|
|
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
|
}`}
|
|
title="Enregistrer le formulaire"
|
|
>
|
|
<Save size={18} />
|
|
<span>
|
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{saveMessage.text && (
|
|
<div
|
|
className={`p-3 rounded ${
|
|
saveMessage.type === 'error'
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-green-100 text-green-700'
|
|
}`}
|
|
>
|
|
{saveMessage.text}
|
|
</div>
|
|
)}
|
|
|
|
<InputTextIcon
|
|
label="Titre du formulaire"
|
|
name="title"
|
|
value={formConfig.title}
|
|
onChange={(e) =>
|
|
setFormConfig({ ...formConfig, title: e.target.value })
|
|
}
|
|
required
|
|
/>
|
|
|
|
<InputTextIcon
|
|
label="Texte du bouton de soumission du formulaire"
|
|
name="submitLabel"
|
|
value={formConfig.submitLabel}
|
|
onChange={(e) =>
|
|
setFormConfig({
|
|
...formConfig,
|
|
submitLabel: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
|
|
{/* Sélecteur de groupes */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Groupes d'inscription{' '}
|
|
<span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-2">
|
|
{groups && groups.length > 0 ? (
|
|
groups.map((group) => (
|
|
<label key={group.id} className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedGroups.includes(group.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedGroups([
|
|
...selectedGroups,
|
|
group.id,
|
|
]);
|
|
} else {
|
|
setSelectedGroups(
|
|
selectedGroups.filter((id) => id !== group.id)
|
|
);
|
|
}
|
|
}}
|
|
className="mr-2 text-blue-600"
|
|
/>
|
|
<span className="text-sm">{group.name}</span>
|
|
</label>
|
|
))
|
|
) : (
|
|
<p className="text-gray-500 text-sm">
|
|
Aucun groupe disponible
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Liste des champs */}
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold mr-4">
|
|
Champs du formulaire ({formConfig.fields.length})
|
|
</h3>
|
|
<button
|
|
onClick={() => {
|
|
setEditingIndex(-1);
|
|
setSelectedFieldType(null);
|
|
setShowFieldTypeSelector(true);
|
|
}}
|
|
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
|
title="Ajouter un champ"
|
|
>
|
|
<PlusCircle size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{formConfig.fields.length === 0 ? (
|
|
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
|
|
<p className="text-gray-500 italic mb-4">
|
|
Aucun champ ajouté
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setEditingIndex(-1);
|
|
setSelectedFieldType(null);
|
|
setShowFieldTypeSelector(true);
|
|
}}
|
|
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
|
|
>
|
|
<PlusCircle size={18} />
|
|
<span>Ajouter mon premier champ</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{formConfig.fields.map((field, index) => (
|
|
<DraggableFieldItem
|
|
key={index}
|
|
field={field}
|
|
index={index}
|
|
moveField={moveField}
|
|
editField={editField}
|
|
deleteField={deleteField}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mt-6">
|
|
{/* Les actions ont été déplacées dans la section JSON généré */}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* JSON généré */}
|
|
{showJsonSection && (
|
|
<div className="lg:col-span-2">
|
|
<div className="bg-white p-6 rounded-lg shadow h-full">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold mr-4">JSON généré</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={exportJson}
|
|
className="p-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
|
|
title="Exporter JSON"
|
|
>
|
|
<Download size={18} />
|
|
</button>
|
|
<label
|
|
className="p-2 bg-orange-500 text-white rounded-md hover:bg-orange-600 cursor-pointer transition-colors"
|
|
title="Importer JSON"
|
|
>
|
|
<Upload size={18} />
|
|
<input
|
|
type="file"
|
|
accept=".json"
|
|
onChange={importJson}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<pre className="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
|
|
{JSON.stringify(formConfig, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Aperçu */}
|
|
<div className="mt-6">
|
|
<div className="bg-white p-6 rounded-lg shadow">
|
|
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
|
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
|
{formConfig.fields.length > 0 ? (
|
|
<FormRenderer formConfig={formConfig} />
|
|
) : (
|
|
<p className="text-gray-500 italic text-center">
|
|
Ajoutez des champs pour voir l'aperçu
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal d'ajout/modification de champ */}
|
|
<AddFieldModal
|
|
isOpen={showAddFieldModal}
|
|
onClose={() => setShowAddFieldModal(false)}
|
|
onSubmit={handleFieldSubmit}
|
|
editingField={
|
|
editingIndex >= 0
|
|
? formConfig.fields[editingIndex]
|
|
: selectedFieldType
|
|
? { type: selectedFieldType.value || selectedFieldType }
|
|
: null
|
|
}
|
|
editingIndex={editingIndex}
|
|
/>
|
|
|
|
{/* Sélecteur de type de champ */}
|
|
<FieldTypeSelector
|
|
isOpen={showFieldTypeSelector}
|
|
onClose={() => setShowFieldTypeSelector(false)}
|
|
onSelect={handleFieldTypeSelect}
|
|
/>
|
|
|
|
{/* Bouton flottant pour remonter en haut */}
|
|
{showScrollButton && (
|
|
<div className="fixed bottom-6 right-6 z-10">
|
|
<button
|
|
onClick={scrollToTop}
|
|
className="p-4 rounded-full shadow-lg flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white transition-all duration-300"
|
|
title="Remonter en haut de la page"
|
|
>
|
|
<ChevronUp size={24} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DndProvider>
|
|
);
|
|
}
|