diff --git a/.DS_Store b/.DS_Store
index 4eeb8ae..714bdfd 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..9572296 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
+.env
+.env.*
# Editor directories and files
.vscode/*
diff --git a/frontend/README.md b/frontend/README.md
index f768e33..fedb6d9 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -6,3 +6,20 @@ Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Configuration Moodle
+
+Les écrans d'administration (liste de cours, gestion des chapitres et lecture sécurisée) consomment l'API REST de Moodle.
+Avant de lancer le projet :
+
+1. Copiez `frontend/.env.example` vers `frontend/.env.local`.
+2. Renseignez vos propres valeurs pour :
+
+```
+VITE_MOODLE_API_URL=https://votre-instance/webservice/rest/server.php
+VITE_MOODLE_TOKEN=token_personnel
+```
+
+> Ne versionnez jamais votre token : `.env` est ignoré par Git.
+
+Ensuite vous pouvez lancer l'application avec `npm install && npm run dev` depuis le dossier `frontend`.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a82b949..5d9876f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"axios": "^1.7.9",
"cloudinary": "^2.7.0",
"pdf-lib": "^1.17.1",
+ "quill": "^1.3.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
diff --git a/frontend/package.json b/frontend/package.json
index 52f2d27..23d594a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,6 +20,7 @@
"axios": "^1.7.9",
"cloudinary": "^2.7.0",
"pdf-lib": "^1.17.1",
+ "quill": "^1.3.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
diff --git a/frontend/src/components/Admin/CoursLecture.jsx b/frontend/src/components/Admin/CoursLecture.jsx
index 43ae109..ff92e59 100644
--- a/frontend/src/components/Admin/CoursLecture.jsx
+++ b/frontend/src/components/Admin/CoursLecture.jsx
@@ -1,27 +1,10 @@
-import React, { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
-import axios from "axios";
import { Box, Typography, CircularProgress } from "@mui/material";
-
-const API_URL =
- "https://www.formations.octopusdesign.fr/webservice/rest/server.php";
-const TOKEN = "685e1b5d794b558b60e971581154c3b2";
+import { MoodleApi } from "../../services/moodleApi";
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
const CoursLecture = () => {
- // Empêche le clic droit
- const handleContextMenu = (e) => e.preventDefault();
- document.addEventListener("contextmenu", handleContextMenu);
-
- // Empêche le copier/coller
- const handleCopyPaste = (e) => e.preventDefault();
- document.addEventListener("copy", handleCopyPaste);
- document.addEventListener("cut", handleCopyPaste);
- document.addEventListener("paste", handleCopyPaste);
-
- // Empêche la sélection de texte
- document.body.style.userSelect = "none";
-
const { token } = useParams();
const [content, setContent] = useState(null);
const [title, setTitle] = useState("");
@@ -30,19 +13,34 @@ const CoursLecture = () => {
// ✅ Masquer Header/Footer pendant l'affichage
useEffect(() => {
+ if (typeof document === "undefined") {
+ return () => {};
+ }
const header = document.querySelector("header");
const footer = document.querySelector("footer");
+ const previousHeaderDisplay = header?.style.display;
+ const previousFooterDisplay = footer?.style.display;
if (header) header.style.display = "none";
if (footer) footer.style.display = "none";
+ const handleContextMenu = (event) => event.preventDefault();
+ const handleCopyPaste = (event) => event.preventDefault();
+ document.addEventListener("contextmenu", handleContextMenu);
+ document.addEventListener("copy", handleCopyPaste);
+ document.addEventListener("cut", handleCopyPaste);
+ document.addEventListener("paste", handleCopyPaste);
+
+ const previousUserSelect = document.body.style.userSelect;
+ document.body.style.userSelect = "none";
+
return () => {
- if (header) header.style.display = "";
- if (footer) footer.style.display = "";
+ if (header) header.style.display = previousHeaderDisplay || "";
+ if (footer) footer.style.display = previousFooterDisplay || "";
document.removeEventListener("contextmenu", handleContextMenu);
document.removeEventListener("copy", handleCopyPaste);
document.removeEventListener("cut", handleCopyPaste);
document.removeEventListener("paste", handleCopyPaste);
- document.body.style.userSelect = "auto";
+ document.body.style.userSelect = previousUserSelect || "auto";
};
}, []);
@@ -77,7 +75,8 @@ const CoursLecture = () => {
linkId,
format: "json",
};
- } catch (jsonError) {
+ } catch (parseError) {
+ console.warn("Lien public JSON invalide :", parseError);
const parts = raw.split("_");
if (parts.length === 3) {
const [courseId, itemId, expiry] = parts;
@@ -103,7 +102,8 @@ const CoursLecture = () => {
}
return null;
}
- } catch (error) {
+ } catch (decodeError) {
+ console.warn("Impossible de décoder le lien sécurisé :", decodeError);
return null;
}
};
@@ -175,29 +175,9 @@ const CoursLecture = () => {
const fetchPage = async () => {
try {
- const res = await axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_page_get_pages_by_courses",
- moodlewsrestformat: "json",
- courseids: [parseInt(courseId, 10)],
- },
- paramsSerializer: (params) => {
- const sp = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((v, i) => sp.append(`${key}[${i}]`, v));
- } else {
- sp.append(key, value);
- }
- });
- return sp.toString();
- },
- });
-
- const found = (res.data.pages || []).find(
- (p) => p.id === parseInt(itemId, 10)
- );
+ const pages = await MoodleApi.getPagesByCourse(Number(courseId));
+ const parsedItemId = parseInt(itemId, 10);
+ const found = pages.find((p) => p.id === parsedItemId);
if (found) {
setTitle(found.name || "");
setContent(found.content || "");
@@ -205,7 +185,8 @@ const CoursLecture = () => {
setTokenValid(false);
setContent("⛔ Page non trouvée.");
}
- } catch (err) {
+ } catch (error) {
+ console.error("Erreur lors du chargement de la page Moodle :", error);
setTokenValid(false);
setContent("❌ Erreur lors du chargement.");
} finally {
@@ -215,19 +196,11 @@ const CoursLecture = () => {
const fetchAssignment = async () => {
try {
- const res = await axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_assign_get_assignments",
- moodlewsrestformat: "json",
- },
- });
- const parsedCourseId = parseInt(courseId, 10);
- const parsedAssignmentId = parseInt(itemId, 10);
- const course = (res.data.courses || []).find(
- (c) => c.id === parsedCourseId
+ const assignments = await MoodleApi.getAssignmentsByCourse(
+ Number(courseId)
);
- const assignment = (course?.assignments || []).find(
+ const parsedAssignmentId = parseInt(itemId, 10);
+ const assignment = assignments.find(
(a) => a.id === parsedAssignmentId
);
if (assignment) {
@@ -238,6 +211,10 @@ const CoursLecture = () => {
setContent("⛔ Devoir introuvable.");
}
} catch (error) {
+ console.error(
+ "Erreur lors du chargement du devoir Moodle :",
+ error
+ );
setTokenValid(false);
setContent("❌ Erreur lors du chargement du devoir.");
} finally {
diff --git a/frontend/src/components/Admin/GestionCours.jsx b/frontend/src/components/Admin/GestionCours.jsx
index 624ec7c..93190dd 100644
--- a/frontend/src/components/Admin/GestionCours.jsx
+++ b/frontend/src/components/Admin/GestionCours.jsx
@@ -1,80 +1,817 @@
-import React, { useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import {
- Box,
- Typography,
- TextField,
- Button,
- CircularProgress,
Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ CircularProgress,
+ Divider,
+ Grid,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ Stack,
+ TextField,
+ Typography,
} from "@mui/material";
-import ReactQuill from "react-quill";
-import "react-quill/dist/quill.snow.css";
-import axios from "axios";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import EditIcon from "@mui/icons-material/Edit";
+import DeleteIcon from "@mui/icons-material/Delete";
+import AddIcon from "@mui/icons-material/Add";
+import { MoodleApi } from "../../services/moodleApi";
+import { isMoodleConfigured } from "../../config/moodle";
+import RichTextEditor from "../common/RichTextEditor";
-const API_URL = "https://www.formations.octopusdesign.fr/webservice/rest/server.php";
-const TOKEN = "685e1b5d794b558b60e971581154c3b2";
+const defaultCourseForm = {
+ fullname: "",
+ shortname: "",
+ categoryid: "1",
+ summary: "",
+};
-const EditeurCours = () => {
- const [titre, setTitre] = useState("");
- const [contenu, setContenu] = useState("");
- const [loading, setLoading] = useState(false);
- const [success, setSuccess] = useState(false);
- const [error, setError] = useState("");
+const defaultSectionForm = {
+ name: "",
+ summary: "",
+ section: "",
+};
- const handleSubmit = async () => {
- if (!titre || !contenu) return;
- setLoading(true);
- setSuccess(false);
- setError("");
+const formatSectionName = (section) => {
+ if (!section) {
+ return "Section sans titre";
+ }
+ const number =
+ section.section !== null && section.section !== undefined
+ ? Number(section.section)
+ : null;
+ if (section.name?.trim()) {
+ return section.name.trim();
+ }
+ if (Number.isFinite(number)) {
+ return `Chapitre ${number}`;
+ }
+ return "Section sans titre";
+};
- const params = new URLSearchParams();
- params.append("wstoken", TOKEN);
- params.append("wsfunction", "core_course_create_courses");
- params.append("moodlewsrestformat", "json");
+const mapCourseToForm = (course) => ({
+ fullname: course.fullname || "",
+ shortname: course.shortname || "",
+ categoryid: String(course.categoryid || "1"),
+ summary: course.summary || "",
+});
- params.append("courses[0][fullname]", titre);
- params.append("courses[0][shortname]", titre.replace(/\s+/g, "_").toLowerCase());
- params.append("courses[0][categoryid]", 1);
- params.append("courses[0][summary]", contenu);
- params.append("courses[0][summaryformat]", 1);
+const GestionCours = () => {
+ const [courses, setCourses] = useState([]);
+ const [courseForm, setCourseForm] = useState(defaultCourseForm);
+ const [selectedCourse, setSelectedCourse] = useState(null);
+ const [sections, setSections] = useState([]);
+ const [sectionForm, setSectionForm] = useState(defaultSectionForm);
+ const [selectedSection, setSelectedSection] = useState(null);
+ const [loadingCourses, setLoadingCourses] = useState(true);
+ const [loadingSections, setLoadingSections] = useState(false);
+ const [courseActionLoading, setCourseActionLoading] = useState(false);
+ const [sectionActionLoading, setSectionActionLoading] = useState(false);
+ const [courseFeedback, setCourseFeedback] = useState(null);
+ const [sectionFeedback, setSectionFeedback] = useState(null);
+ const [searchValue, setSearchValue] = useState("");
+ const handleResetSectionForm = useCallback(() => {
+ setSelectedSection(null);
+ setSectionForm(defaultSectionForm);
+ }, []);
+
+ const handleResetCourseSelection = useCallback(() => {
+ setSelectedCourse(null);
+ setCourseForm(defaultCourseForm);
+ setSections([]);
+ handleResetSectionForm();
+ }, [handleResetSectionForm]);
+
+ const refreshCourses = useCallback(async (preserveCourseId) => {
try {
- await axios.post(API_URL, params);
- setSuccess(true);
- setTitre("");
- setContenu("");
- } catch (err) {
- setError("Erreur lors de la création du cours.");
- console.error(err);
+ if (!isMoodleConfigured()) {
+ return;
+ }
+ setLoadingCourses(true);
+ const data = await MoodleApi.listCourses();
+ const sanitized = (Array.isArray(data) ? data : []).filter(
+ (course) => course.id !== 1
+ );
+ setCourses(sanitized);
+ if (preserveCourseId) {
+ const stillExists = sanitized.find((c) => c.id === preserveCourseId);
+ if (stillExists) {
+ setSelectedCourse(stillExists);
+ setCourseForm(mapCourseToForm(stillExists));
+ } else {
+ handleResetCourseSelection();
+ }
+ }
+ } catch (error) {
+ console.error("Erreur lors du chargement des cours :", error);
+ setCourseFeedback({
+ type: "error",
+ message:
+ error?.message || "Impossible de récupérer la liste des cours.",
+ });
} finally {
- setLoading(false);
+ setLoadingCourses(false);
+ }
+ }, [handleResetCourseSelection]);
+
+ useEffect(() => {
+ if (!isMoodleConfigured()) {
+ setCourseFeedback({
+ type: "error",
+ message:
+ "Configurez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN pour utiliser cette interface.",
+ });
+ setLoadingCourses(false);
+ return;
+ }
+ refreshCourses();
+ }, [refreshCourses]);
+
+ const refreshSections = async (courseId) => {
+ if (!courseId) {
+ setSections([]);
+ return;
+ }
+ setLoadingSections(true);
+ try {
+ const data = await MoodleApi.getCourseContents(courseId);
+ const normalized = (Array.isArray(data) ? data : []).map((section) => ({
+ id: section.id,
+ section: section.section,
+ name: formatSectionName(section),
+ summary: section.summary || "",
+ }));
+ setSections(normalized);
+ } catch (error) {
+ console.error("Erreur lors du chargement des sections :", error);
+ setSectionFeedback({
+ type: "error",
+ message:
+ error?.message || "Impossible de récupérer les sections du cours.",
+ });
+ } finally {
+ setLoadingSections(false);
}
};
+ const handleSelectCourse = (course) => {
+ setSelectedCourse(course);
+ setCourseForm(mapCourseToForm(course));
+ handleResetSectionForm();
+ refreshSections(course.id);
+ };
+
+ const handleCourseInputChange = (field) => (event) => {
+ const { value } = event.target;
+ setCourseForm((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+ };
+
+ const handleSummaryChange = (value) => {
+ setCourseForm((prev) => ({
+ ...prev,
+ summary: value,
+ }));
+ };
+
+ const slugify = (value) =>
+ value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^\w\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .toLowerCase();
+
+ const buildCoursePayload = () => {
+ const fullname = courseForm.fullname.trim();
+ const shortname =
+ courseForm.shortname.trim() || slugify(courseForm.fullname);
+ if (!fullname || !shortname) {
+ throw new Error(
+ "Le titre et l'identifiant court du cours sont obligatoires."
+ );
+ }
+ const payload = {
+ fullname,
+ shortname,
+ summary: courseForm.summary || "",
+ summaryformat: 1,
+ };
+ const normalizedCategory =
+ courseForm.categoryid && !Number.isNaN(Number(courseForm.categoryid))
+ ? Number(courseForm.categoryid)
+ : null;
+ if (normalizedCategory !== null) {
+ payload.categoryid = normalizedCategory;
+ }
+ return payload;
+ };
+
+ const handleCreateCourse = async () => {
+ setCourseFeedback(null);
+ try {
+ setCourseActionLoading(true);
+ const payload = buildCoursePayload();
+ await MoodleApi.createCourse(payload);
+ setCourseFeedback({
+ type: "success",
+ message: "Cours créé avec succès.",
+ });
+ setCourseForm(defaultCourseForm);
+ await refreshCourses();
+ } catch (error) {
+ setCourseFeedback({
+ type: "error",
+ message: error?.message || "Impossible de créer le cours.",
+ });
+ } finally {
+ setCourseActionLoading(false);
+ }
+ };
+
+ const handleUpdateCourse = async () => {
+ if (!selectedCourse?.id) {
+ return;
+ }
+ setCourseFeedback(null);
+ try {
+ setCourseActionLoading(true);
+ const payload = buildCoursePayload();
+ await MoodleApi.updateCourse({
+ id: Number(selectedCourse.id),
+ ...payload,
+ });
+ setCourseFeedback({
+ type: "success",
+ message: "Cours mis à jour.",
+ });
+ await refreshCourses(selectedCourse.id);
+ } catch (error) {
+ setCourseFeedback({
+ type: "error",
+ message: error?.message || "Impossible de mettre à jour le cours.",
+ });
+ } finally {
+ setCourseActionLoading(false);
+ }
+ };
+
+ const handleDeleteCourse = async (courseArg) => {
+ const targetCourse = courseArg || selectedCourse;
+ if (!targetCourse?.id) {
+ return;
+ }
+ const confirmed = window.confirm(
+ "Supprimer définitivement ce cours et tous ses contenus ?"
+ );
+ if (!confirmed) {
+ return;
+ }
+ setCourseFeedback(null);
+ try {
+ setCourseActionLoading(true);
+ await MoodleApi.deleteCourses([targetCourse.id]);
+ setCourseFeedback({
+ type: "success",
+ message: "Cours supprimé.",
+ });
+ if (selectedCourse?.id === targetCourse.id) {
+ handleResetCourseSelection();
+ }
+ await refreshCourses();
+ } catch (error) {
+ setCourseFeedback({
+ type: "error",
+ message: error?.message || "Impossible de supprimer le cours.",
+ });
+ } finally {
+ setCourseActionLoading(false);
+ }
+ };
+
+ const handleSectionInputChange = (field) => (event) => {
+ const { value } = event.target;
+ setSectionForm((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+ };
+
+ const handleEditSection = (section) => {
+ setSelectedSection(section);
+ setSectionForm({
+ name: section.name || "",
+ summary: section.summary || "",
+ section:
+ section.section !== null && section.section !== undefined
+ ? String(section.section)
+ : "",
+ });
+ };
+
+ const handleCreateSection = async () => {
+ if (!selectedCourse?.id) {
+ return;
+ }
+ setSectionFeedback(null);
+ try {
+ setSectionActionLoading(true);
+ const payload = {
+ courseid: selectedCourse.id,
+ name: sectionForm.name?.trim() || undefined,
+ summary: sectionForm.summary || "",
+ };
+ if (sectionForm.section) {
+ payload.section = Number(sectionForm.section);
+ }
+ await MoodleApi.createSection(payload);
+ setSectionFeedback({
+ type: "success",
+ message: "Chapitre ajouté.",
+ });
+ handleResetSectionForm();
+ await refreshSections(selectedCourse.id);
+ } catch (error) {
+ setSectionFeedback({
+ type: "error",
+ message: error?.message || "Impossible d'ajouter le chapitre.",
+ });
+ } finally {
+ setSectionActionLoading(false);
+ }
+ };
+
+ const handleUpdateSection = async () => {
+ if (!selectedSection?.id || !selectedCourse?.id) {
+ return;
+ }
+ setSectionFeedback(null);
+ try {
+ setSectionActionLoading(true);
+ await MoodleApi.updateSection({
+ id: selectedSection.id,
+ courseid: selectedCourse.id,
+ name: sectionForm.name?.trim() || undefined,
+ summary: sectionForm.summary || "",
+ section:
+ sectionForm.section !== ""
+ ? Number(sectionForm.section)
+ : undefined,
+ });
+ setSectionFeedback({
+ type: "success",
+ message: "Chapitre mis à jour.",
+ });
+ handleResetSectionForm();
+ await refreshSections(selectedCourse.id);
+ } catch (error) {
+ setSectionFeedback({
+ type: "error",
+ message: error?.message || "Impossible de mettre à jour le chapitre.",
+ });
+ } finally {
+ setSectionActionLoading(false);
+ }
+ };
+
+ const handleDeleteSection = async (section) => {
+ if (!selectedCourse?.id || !section?.id) {
+ return;
+ }
+ const confirmed = window.confirm(
+ "Supprimer définitivement ce chapitre ?"
+ );
+ if (!confirmed) {
+ return;
+ }
+ setSectionFeedback(null);
+ try {
+ setSectionActionLoading(true);
+ await MoodleApi.deleteSections(selectedCourse.id, [section.id]);
+ setSectionFeedback({
+ type: "success",
+ message: "Chapitre supprimé.",
+ });
+ if (selectedSection?.id === section.id) {
+ handleResetSectionForm();
+ }
+ await refreshSections(selectedCourse.id);
+ } catch (error) {
+ setSectionFeedback({
+ type: "error",
+ message: error?.message || "Impossible de supprimer le chapitre.",
+ });
+ } finally {
+ setSectionActionLoading(false);
+ }
+ };
+
+ const filteredCourses = useMemo(() => {
+ if (!searchValue.trim()) {
+ return courses;
+ }
+ const query = searchValue.trim().toLowerCase();
+ return courses.filter((course) =>
+ course.fullname?.toLowerCase().includes(query)
+ );
+ }, [courses, searchValue]);
+
+ const sectionList = useMemo(() => {
+ const clone = [...sections];
+ clone.sort((a, b) => {
+ const valueA =
+ a.section === null || a.section === undefined ? 9999 : a.section;
+ const valueB =
+ b.section === null || b.section === undefined ? 9999 : b.section;
+ return valueA - valueB;
+ });
+ return clone;
+ }, [sections]);
+
return (
-
-
- Ajouter un cours dans Moodle
-
+
+
+
+
+
+ Gestion des cours et chapitres
+
+ }
+ onClick={() => refreshCourses(selectedCourse?.id)}
+ disabled={loadingCourses}
+ variant="outlined"
+ >
+ Actualiser
+
+
+
+ Créez, modifiez et supprimez vos cours Moodle directement depuis
+ cette interface. Les chapitres correspondent aux sections du cours.
+
+
- {success && Cours enregistré avec succès !}
- {error && {error}}
+ {!isMoodleConfigured() && (
+
+ Ajoutez vos identifiants Moodle dans .env.local pour
+ activer cette page.
+
+ )}
- setTitre(e.target.value)}
- sx={{ mb: 3 }}
- />
+ {courseFeedback && (
+ {courseFeedback.message}
+ )}
-
+
+
+
+
+
+
+
+ Liste des cours
+
+ setSearchValue(event.target.value)}
+ />
+
+
+ {loadingCourses ? (
+
+
+
+ ) : (
+
+ {filteredCourses.length === 0 && (
+
+
+
+ )}
+ {filteredCourses.map((course) => (
+ handleDeleteCourse(course)}
+ disabled={courseActionLoading}
+ >
+
+
+ }
+ >
+ handleSelectCourse(course)}
+ />
+
+ ))}
+
+ )}
+
+
+
+
-
+
+
+
+
+
+
+ {selectedCourse ? "Modifier le cours" : "Nouveau cours"}
+
+ {selectedCourse && (
+ }
+ onClick={handleResetCourseSelection}
+ >
+ Nouveau
+
+ )}
+
+
+
+
+
+
+ Résumé du cours
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedCourse && (
+
+
+
+
+
+
+ Chapitres de « {selectedCourse.fullname} »
+
+
+ Gérez les sections du cours Moodle sélectionné.
+
+
+ }
+ onClick={() => refreshSections(selectedCourse.id)}
+ disabled={loadingSections}
+ >
+ Recharger
+
+
+
+ {sectionFeedback && (
+
+ {sectionFeedback.message}
+
+ )}
+
+
+
+ {loadingSections ? (
+
+
+
+ ) : (
+
+ {sectionList.length === 0 && (
+
+
+
+ )}
+ {sectionList.map((section) => (
+
+ handleEditSection(section)}
+ >
+
+
+ handleDeleteSection(section)}
+ disabled={sectionActionLoading}
+ >
+
+
+
+ }
+ >
+
+
+ ))}
+
+ )}
+
+
+
+
+ {selectedSection
+ ? "Modifier le chapitre"
+ : "Nouveau chapitre"}
+
+
+
+
+
+
+ {selectedSection && (
+
+ )}
+
+
+
+
+
+
+
+ )}
+
);
};
-export default EditeurCours;
\ No newline at end of file
+export default GestionCours;
diff --git a/frontend/src/components/Admin/ListeCours.jsx b/frontend/src/components/Admin/ListeCours.jsx
index f3e4bcf..9402a6a 100644
--- a/frontend/src/components/Admin/ListeCours.jsx
+++ b/frontend/src/components/Admin/ListeCours.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import {
Box,
Typography,
@@ -27,21 +27,29 @@ import {
Select,
MenuItem,
IconButton,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Tabs,
+ Tab,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import DoneAllIcon from "@mui/icons-material/DoneAll";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CloseIcon from "@mui/icons-material/Close";
-import axios from "axios";
-import { useNavigate } from "react-router-dom";
+import ChevronRightIcon from "@mui/icons-material/ChevronRight";
+import { Link, useNavigate } from "react-router-dom";
import { getToken } from "../../auth";
+import { MoodleApi } from "../../services/moodleApi";
+import {
+ isMoodleConfigured,
+ MOODLE_API_URL,
+ MOODLE_TOKEN,
+} from "../../config/moodle";
import "../../assets/styleCours.css";
-const API_URL =
- "https://www.formations.octopusdesign.fr/webservice/rest/server.php";
-const TOKEN = "685e1b5d794b558b60e971581154c3b2";
-
const ITEM_TYPE_META = {
page: { label: "Cours", chipColor: "primary" },
assignment: { label: "Devoir", chipColor: "secondary" },
@@ -223,6 +231,7 @@ const ListeCours = () => {
const [h5pActivities, setH5pActivities] = useState([]);
const [quizzes, setQuizzes] = useState([]);
const [moduleSections, setModuleSections] = useState({});
+ const [courseSections, setCourseSections] = useState([]);
const [viewedPages, setViewedPages] = useState({});
const [viewedAssignments, setViewedAssignments] = useState({});
const [viewedH5P, setViewedH5P] = useState({});
@@ -231,6 +240,14 @@ const ListeCours = () => {
const [linkDurationSelections, setLinkDurationSelections] = useState({});
const [progressByCourse, setProgressByCourse] = useState({});
const [courseSearch, setCourseSearch] = useState("");
+ const [draggingItem, setDraggingItem] = useState(null);
+ const [hoveredSection, setHoveredSection] = useState(null);
+ const [unitActionLoading, setUnitActionLoading] = useState(false);
+ const [unitActionError, setUnitActionError] = useState("");
+ const [unitActionSuccess, setUnitActionSuccess] = useState("");
+ const [modalActiveTab, setModalActiveTab] = useState("programmes");
+ const modalTabBeforeDrag = useRef(null);
+ const [configMissing, setConfigMissing] = useState(!isMoodleConfigured());
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -243,7 +260,7 @@ const ListeCours = () => {
} catch (error) {
console.error("Impossible de charger les liens publics :", error);
}
- }, []);
+ }, [MOODLE_API_URL]);
const normalizeSectionValue = React.useCallback((value) => {
if (value === null || value === undefined) {
return null;
@@ -467,6 +484,9 @@ const ListeCours = () => {
registerSection(activity.section, activity.sectionname)
);
quizzes.forEach((quiz) => registerSection(quiz.section, quiz.sectionname));
+ courseSections.forEach((section) =>
+ registerSection(section.section, section.name)
+ );
return Array.from(optionsMap.values());
}, [
@@ -474,6 +494,7 @@ const ListeCours = () => {
assignments,
h5pActivities,
quizzes,
+ courseSections,
formatSectionLabel,
normalizeSectionValue,
]);
@@ -575,102 +596,39 @@ const ListeCours = () => {
);
const fetchCours = async () => {
+ if (!isMoodleConfigured()) {
+ setConfigMissing(true);
+ setLoading(false);
+ return;
+ }
try {
- const res = await axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "core_course_get_courses",
- moodlewsrestformat: "json",
- },
- });
- setCours(res.data);
+ setConfigMissing(false);
+ const data = await MoodleApi.listCourses();
+ setCours(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Erreur chargement cours :", err);
+ setCours([]);
} finally {
setLoading(false);
}
};
const fetchPages = async (courseId) => {
+ if (!isMoodleConfigured()) {
+ setConfigMissing(true);
+ setLoadingPages(false);
+ return;
+ }
setLoadingPages(true);
try {
const [pagesRes, sectionsRes, h5pRes, quizzesRes] = await Promise.all([
- axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_page_get_pages_by_courses",
- moodlewsrestformat: "json",
- courseids: [courseId],
- },
- paramsSerializer: (params) => {
- const searchParams = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((v, i) =>
- searchParams.append(`${key}[${i}]`, v)
- );
- } else {
- searchParams.append(key, value);
- }
- });
- return searchParams.toString();
- },
- }),
- axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "core_course_get_contents",
- moodlewsrestformat: "json",
- courseid: courseId,
- },
- }),
- axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_h5pactivity_get_h5pactivities_by_courses",
- moodlewsrestformat: "json",
- courseids: [courseId],
- },
- paramsSerializer: (params) => {
- const searchParams = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((v, i) =>
- searchParams.append(`${key}[${i}]`, v)
- );
- } else {
- searchParams.append(key, value);
- }
- });
- return searchParams.toString();
- },
- }),
- axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_quiz_get_quizzes_by_courses",
- moodlewsrestformat: "json",
- courseids: [courseId],
- },
- paramsSerializer: (params) => {
- const searchParams = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((v, i) =>
- searchParams.append(`${key}[${i}]`, v)
- );
- } else {
- searchParams.append(key, value);
- }
- });
- return searchParams.toString();
- },
- }),
+ MoodleApi.getPagesByCourse(courseId),
+ MoodleApi.getCourseContents(courseId),
+ MoodleApi.getH5PActivitiesByCourse(courseId),
+ MoodleApi.getQuizzesByCourse(courseId),
]);
- const sectionsData = Array.isArray(sectionsRes.data)
- ? sectionsRes.data
- : [];
+ const sectionsData = Array.isArray(sectionsRes) ? sectionsRes : [];
const nameMap = sectionsData.reduce((acc, section) => {
const sectionIndex = Number(section.section);
if (Number.isNaN(sectionIndex)) {
@@ -687,6 +645,33 @@ const ListeCours = () => {
return acc;
}, {});
setSectionNames(nameMap);
+ const formattedSections = sectionsData.map((section) => {
+ const sectionIndex = Number(section.section);
+ const normalizedSection = Number.isNaN(sectionIndex)
+ ? section.section ?? null
+ : sectionIndex;
+ const plainSummary = section.summary
+ ? section.summary.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
+ : "";
+ const label =
+ (normalizedSection !== null &&
+ normalizedSection !== undefined &&
+ nameMap[normalizedSection]) ||
+ (section.name && section.name.trim()) ||
+ (plainSummary || null) ||
+ (Number.isFinite(Number(section.section))
+ ? `Chapitre ${section.section}`
+ : "Unité sans titre");
+ return {
+ id: section.id,
+ section: normalizedSection,
+ name: label,
+ summary: section.summary || "",
+ summaryformat: section.summaryformat,
+ visible: section.visible,
+ };
+ });
+ setCourseSections(formattedSections);
let globalModuleOrder = 0;
const moduleMap = {};
@@ -707,7 +692,7 @@ const ListeCours = () => {
});
});
- const pagesData = (pagesRes.data.pages || []).map((page) => ({
+ const pagesData = (Array.isArray(pagesRes) ? pagesRes : []).map((page) => ({
...page,
sectionname:
nameMap[page.section] ||
@@ -715,9 +700,7 @@ const ListeCours = () => {
`Chapitre ${page.section}`,
}));
- const h5pActivitiesData = (
- (h5pRes.data && h5pRes.data.h5pactivities) || []
- ).map((activity) => {
+ const h5pActivitiesData = (Array.isArray(h5pRes) ? h5pRes : []).map((activity) => {
const moduleInfo = moduleMap[activity.coursemodule] || {};
const section = moduleInfo.section ?? null;
const sectionLabel =
@@ -734,17 +717,24 @@ const ListeCours = () => {
const normalizedPackageUrl = packageFileUrl
? packageFileUrl.replace("/webservice", "")
: "";
- const embedUrl = normalizedPackageUrl
- ? `${new URL(normalizedPackageUrl).origin}/h5p/embed.php?url=${encodeURIComponent(
+ let embedUrl = "";
+ if (normalizedPackageUrl) {
+ try {
+ const parsedUrl = new URL(normalizedPackageUrl);
+ embedUrl = `${parsedUrl.origin}/h5p/embed.php?url=${encodeURIComponent(
normalizedPackageUrl
- )}&component=mod_h5pactivity`
- : "";
+ )}&component=mod_h5pactivity`;
+ } catch (error) {
+ console.error("URL H5P invalide :", error);
+ }
+ }
- const downloadUrl = packageFileUrl
- ? `${packageFileUrl}${
- packageFileUrl.includes("?") ? "&" : "?"
- }token=${TOKEN}`
- : null;
+ const downloadUrl =
+ packageFileUrl && MOODLE_TOKEN
+ ? `${packageFileUrl}${
+ packageFileUrl.includes("?") ? "&" : "?"
+ }token=${MOODLE_TOKEN}`
+ : packageFileUrl || null;
return {
id: activity.id,
@@ -762,7 +752,7 @@ const ListeCours = () => {
setPages(pagesData);
setModuleSections(moduleMap);
setH5pActivities(h5pActivitiesData);
- const quizzesData = (quizzesRes.data.quizzes || []).map((quiz) => {
+ const quizzesData = (Array.isArray(quizzesRes) ? quizzesRes : []).map((quiz) => {
const moduleInfo = moduleMap[quiz.coursemodule] || {};
const section = moduleInfo.section ?? null;
const sectionLabel = formatSectionLabel(
@@ -781,27 +771,26 @@ const ListeCours = () => {
});
setQuizzes(quizzesData);
setCurrentPage(1);
- } catch (err) {
- console.error("Erreur chargement pages :", err);
- setH5pActivities([]);
- setModuleSections({});
- setQuizzes([]);
- } finally {
- setLoadingPages(false);
- }
- };
+ } catch (err) {
+ console.error("Erreur chargement pages :", err);
+ setH5pActivities([]);
+ setModuleSections({});
+ setCourseSections([]);
+ setQuizzes([]);
+ setPages([]);
+ } finally {
+ setLoadingPages(false);
+ }
+};
const fetchAssignments = async (courseId) => {
+ if (!isMoodleConfigured()) {
+ setConfigMissing(true);
+ return;
+ }
try {
- const res = await axios.get(API_URL, {
- params: {
- wstoken: TOKEN,
- wsfunction: "mod_assign_get_assignments",
- moodlewsrestformat: "json",
- },
- });
- const found = res.data.courses.find((c) => c.id === courseId);
- setAssignmentsRaw(found?.assignments || []);
+ const assignments = await MoodleApi.getAssignmentsByCourse(courseId);
+ setAssignmentsRaw(assignments);
} catch (err) {
console.error("Erreur chargement devoirs :", err);
setAssignmentsRaw([]);
@@ -877,11 +866,19 @@ const ListeCours = () => {
setSelectedH5P(null);
setSelectedQuiz(null);
setModuleSections({});
+ setCourseSections([]);
setQuizzes([]);
setSearchQuery("");
setShowOnlyMedia(false);
setAssignments([]);
setAssignmentsRaw([]);
+ setUnitActionError("");
+ setUnitActionSuccess("");
+ setUnitActionLoading(false);
+ setDraggingItem(null);
+ setHoveredSection(null);
+ setModalActiveTab("programmes");
+ modalTabBeforeDrag.current = null;
loadProgress(cours.id);
fetchPages(cours.id);
fetchAssignments(cours.id);
@@ -895,11 +892,19 @@ const ListeCours = () => {
setH5pActivities([]);
setSelectedH5P(null);
setModuleSections({});
+ setCourseSections([]);
setAssignments([]);
setAssignmentsRaw([]);
setQuizzes([]);
setSearchQuery("");
setShowOnlyMedia(false);
+ setUnitActionError("");
+ setUnitActionSuccess("");
+ setUnitActionLoading(false);
+ setDraggingItem(null);
+ setHoveredSection(null);
+ setModalActiveTab("programmes");
+ modalTabBeforeDrag.current = null;
}, []);
const buildPdfContainer = (titleText, htmlContent) => {
@@ -1075,17 +1080,18 @@ const ListeCours = () => {
if (typeof window === "undefined") {
return;
}
- if (!document.getElementById(scriptId)) {
- try {
- const h5pOrigin = new URL(API_URL).origin;
- const script = document.createElement("script");
- script.id = scriptId;
- script.src = `${h5pOrigin}/h5p/h5plib/v127/joubel/core/js/h5p-resizer.js`;
- script.async = true;
- document.body.appendChild(script);
- } catch (error) {
- console.error("Impossible de charger le script H5P resizer :", error);
- }
+ if (!MOODLE_API_URL || document.getElementById(scriptId)) {
+ return;
+ }
+ try {
+ const h5pOrigin = new URL(MOODLE_API_URL).origin;
+ const script = document.createElement("script");
+ script.id = scriptId;
+ script.src = `${h5pOrigin}/h5p/h5plib/v127/joubel/core/js/h5p-resizer.js`;
+ script.async = true;
+ document.body.appendChild(script);
+ } catch (error) {
+ console.error("Impossible de charger le script H5P resizer :", error);
}
}, []);
@@ -1181,6 +1187,12 @@ const ListeCours = () => {
key: `page-${page.id}`,
id: page.id,
name: page.name,
+ moduleId:
+ moduleInfo?.id ??
+ moduleInfo?.cmid ??
+ page.coursemodule ??
+ page.cmid ??
+ null,
section: sectionValue,
sectionLabel: formatSectionLabel(sectionValue, page.sectionname),
data: page,
@@ -1202,6 +1214,12 @@ const ListeCours = () => {
key: `assignment-${assignment.id}`,
id: assignment.id,
name: assignment.name,
+ moduleId:
+ moduleInfo?.id ??
+ moduleInfo?.cmid ??
+ assignment.cmid ??
+ assignment.coursemodule ??
+ null,
section: sectionValue,
sectionLabel: formatSectionLabel(sectionValue, assignment.sectionname),
data: assignment,
@@ -1223,6 +1241,12 @@ const ListeCours = () => {
key: `quiz-${quiz.id}`,
id: quiz.id,
name: quiz.name,
+ moduleId:
+ moduleInfo?.id ??
+ moduleInfo?.cmid ??
+ quiz.coursemodule ??
+ quiz.cmid ??
+ null,
section: sectionValue,
sectionLabel: formatSectionLabel(sectionValue, quiz.sectionname),
data: quiz,
@@ -1244,6 +1268,12 @@ const ListeCours = () => {
key: `h5p-${activity.id}`,
id: activity.id,
name: activity.name,
+ moduleId:
+ moduleInfo?.id ??
+ moduleInfo?.cmid ??
+ activity.coursemodule ??
+ activity.cmid ??
+ null,
section: sectionValue,
sectionLabel: formatSectionLabel(sectionValue, activity.sectionname),
data: activity,
@@ -1351,6 +1381,84 @@ const ListeCours = () => {
viewedQuizzes,
]);
+ const unitPanelData = React.useMemo(() => {
+ if (!Array.isArray(courseSections) || courseSections.length === 0) {
+ return [];
+ }
+ return courseSections
+ .map((section) => {
+ const normalizedSection = normalizeSectionValue(section.section);
+ const label = formatSectionLabel(
+ normalizedSection,
+ section.name || undefined
+ );
+ const itemsForSection = programItems.filter(
+ (item) =>
+ normalizeSectionValue(item.section) === normalizedSection
+ );
+ const pagesForSection = itemsForSection.filter(
+ (item) => item.type === "page"
+ );
+ const mappedItems = itemsForSection.map((item) => {
+ const meta = ITEM_TYPE_META[item.type] || {
+ label: "Contenu",
+ };
+ return {
+ id: item.id,
+ moduleId: item.moduleId,
+ name: item.name,
+ type: item.type,
+ typeLabel: meta.label,
+ typeChipColor: meta.chipColor || "default",
+ hasMedia:
+ item.type === "page" &&
+ pageHasMedia(item.data?.content || ""),
+ };
+ });
+ const stats =
+ normalizedSection !== null && normalizedSection !== undefined
+ ? chapterStats[normalizedSection] || { total: 0, viewed: 0 }
+ : { total: 0, viewed: 0 };
+ return {
+ id:
+ section.id ??
+ (normalizedSection !== null && normalizedSection !== undefined
+ ? `section-${normalizedSection}`
+ : `section-${Math.random().toString(36).slice(2)}`),
+ sectionNumber: normalizedSection,
+ label,
+ summary: section.summary || "",
+ totalItems: itemsForSection.length,
+ totalChapters: pagesForSection.length,
+ items: mappedItems,
+ progress: {
+ total: stats.total,
+ viewed: stats.viewed,
+ },
+ };
+ })
+ .sort((a, b) => {
+ const sectionA = a.sectionNumber;
+ const sectionB = b.sectionNumber;
+ if (sectionA === sectionB) {
+ return 0;
+ }
+ if (sectionA === null || sectionA === undefined) {
+ return 1;
+ }
+ if (sectionB === null || sectionB === undefined) {
+ return -1;
+ }
+ return sectionA < sectionB ? -1 : 1;
+ });
+ }, [
+ courseSections,
+ chapterStats,
+ programItems,
+ formatSectionLabel,
+ normalizeSectionValue,
+ ]);
+
const isChapterComplete = React.useCallback(
(sectionValue) => {
const key = normalizeSectionValue(sectionValue);
@@ -1490,6 +1598,188 @@ const ListeCours = () => {
currentPage * itemsPerPage
);
+ const handleProgramItemDragStart = (item) => (event) => {
+ if (!item?.moduleId) {
+ return;
+ }
+ try {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData(
+ "application/json",
+ JSON.stringify({
+ moduleId: item.moduleId,
+ section: normalizeSectionValue(item.section),
+ label: item.name,
+ })
+ );
+ } catch (error) {
+ console.error("Impossible de préparer le glisser-déposer :", error);
+ }
+ setUnitActionError("");
+ setUnitActionSuccess("");
+ setDraggingItem({
+ moduleId: item.moduleId,
+ section: normalizeSectionValue(item.section),
+ label: item.name,
+ });
+ if (modalActiveTab !== "unites") {
+ modalTabBeforeDrag.current = modalActiveTab;
+ setModalActiveTab("unites");
+ refreshSelectedCourse();
+ } else {
+ modalTabBeforeDrag.current = "unites";
+ }
+ };
+
+ const handleProgramItemDragEnd = () => {
+ setDraggingItem(null);
+ setHoveredSection(null);
+ if (
+ modalTabBeforeDrag.current &&
+ modalTabBeforeDrag.current !== "unites"
+ ) {
+ setModalActiveTab(modalTabBeforeDrag.current);
+ }
+ modalTabBeforeDrag.current = null;
+ };
+
+ const handleUnitDragOver = (sectionNumber) => (event) => {
+ if (unitActionLoading) {
+ return;
+ }
+ const hasData =
+ draggingItem ||
+ (event.dataTransfer &&
+ Array.from(event.dataTransfer.types || []).includes(
+ "application/json"
+ ));
+ if (!hasData) {
+ return;
+ }
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ setHoveredSection(normalizeSectionValue(sectionNumber));
+ };
+
+ const handleUnitDragLeave = (sectionNumber) => () => {
+ setHoveredSection((current) => {
+ const normalizedSection = normalizeSectionValue(sectionNumber);
+ if (current === normalizedSection) {
+ return null;
+ }
+ return current;
+ });
+ };
+
+ const handleUnitDrop = (sectionNumber) => async (event) => {
+ event.preventDefault();
+ const normalizedTarget = normalizeSectionValue(sectionNumber);
+ setHoveredSection(null);
+ let payload = draggingItem;
+ if (!payload && event.dataTransfer) {
+ const raw = event.dataTransfer.getData("application/json");
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && parsed.moduleId) {
+ payload = {
+ moduleId: parsed.moduleId,
+ section: normalizeSectionValue(parsed.section),
+ label: parsed.label,
+ };
+ }
+ } catch (error) {
+ console.error("Impossible de lire la donnée déposée :", error);
+ }
+ }
+ }
+ if (!payload?.moduleId) {
+ return;
+ }
+ const currentSection = normalizeSectionValue(payload.section);
+ if (
+ currentSection !== null &&
+ currentSection !== undefined &&
+ normalizedTarget !== null &&
+ normalizedTarget !== undefined &&
+ currentSection === normalizedTarget
+ ) {
+ setUnitActionError("Le chapitre se trouve déjà dans cette unité.");
+ setDraggingItem(null);
+ return;
+ }
+ await moveModuleToSection(payload.moduleId, normalizedTarget);
+ };
+
+ const moveModuleToSection = async (moduleId, targetSection) => {
+ if (!selected?.id) {
+ return;
+ }
+ if (!isMoodleConfigured()) {
+ setConfigMissing(true);
+ setUnitActionError(
+ "Configuration Moodle absente. Ajoutez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN."
+ );
+ return;
+ }
+ if (!moduleId && moduleId !== 0) {
+ return;
+ }
+ const normalizedTarget = normalizeSectionValue(targetSection);
+ const numericSection =
+ normalizedTarget === null || normalizedTarget === undefined
+ ? null
+ : Number(normalizedTarget);
+ if (numericSection === null || Number.isNaN(numericSection)) {
+ setUnitActionError("Section cible invalide pour ce déplacement.");
+ return;
+ }
+ if (unitActionLoading) {
+ return;
+ }
+ setUnitActionLoading(true);
+ setUnitActionError("");
+ setUnitActionSuccess("");
+
+ try {
+ await MoodleApi.moveModuleToSection(moduleId, numericSection);
+ setUnitActionSuccess("Chapitre déplacé avec succès.");
+ await Promise.all([
+ fetchPages(selected.id),
+ fetchAssignments(selected.id),
+ ]);
+ } catch (error) {
+ console.error("Erreur lors du déplacement du chapitre :", error);
+ const serverMessage =
+ error?.response?.data?.message ||
+ error?.response?.data?.error ||
+ error?.message ||
+ "";
+ setUnitActionError(
+ `Impossible de déplacer le chapitre.${
+ serverMessage ? ` ${serverMessage}` : ""
+ }`
+ );
+ } finally {
+ setUnitActionLoading(false);
+ setDraggingItem(null);
+ setHoveredSection(null);
+ }
+ };
+
+ const refreshSelectedCourse = React.useCallback(() => {
+ if (!selected?.id) {
+ return;
+ }
+ setUnitActionLoading(true);
+ Promise.all([
+ fetchPages(selected.id),
+ fetchAssignments(selected.id),
+ ]).finally(() => {
+ setUnitActionLoading(false);
+ });
+ }, [selected?.id, fetchPages, fetchAssignments]);
+
const programmeInfoMessage =
"Visualisez les cours, devoirs, tests et activités interactives depuis un seul espace.";
@@ -1566,6 +1856,32 @@ const ListeCours = () => {
]
);
+ const openProgramItem = React.useCallback(
+ (itemId, itemType = "page") => {
+ if (!itemId) {
+ return;
+ }
+ const target = programItems.find(
+ (candidate) =>
+ candidate &&
+ candidate.id === itemId &&
+ candidate.type === itemType
+ );
+ if (target) {
+ handleItemTitleClick(target);
+ }
+ },
+ [programItems, handleItemTitleClick]
+ );
+
+ const handleModalTabChange = (event, value) => {
+ modalTabBeforeDrag.current = null;
+ setModalActiveTab(value);
+ if (value === "unites") {
+ refreshSelectedCourse();
+ }
+ };
+
return (
{
/>
+ {configMissing && (
+
+ Ajoutez vos identifiants Moodle dans .env.local, puis
+ relancez le serveur pour activer cette interface.
+
+ )}
+
{
-
+
+
+
+
+
+
+
+
+
+
Programmes du cours
@@ -2416,6 +2773,19 @@ const ListeCours = () => {
{
+ if (!item.moduleId) {
+ event.preventDefault();
+ return;
+ }
+ event.stopPropagation();
+ handleProgramItemDragStart(item)(event);
+ }}
+ onDragEnd={(event) => {
+ event.stopPropagation();
+ handleProgramItemDragEnd();
+ }}
sx={{
mb: 4,
p: { xs: 2.5, md: 3 },
@@ -2424,6 +2794,7 @@ const ListeCours = () => {
border: "1px solid rgba(255,255,255,0.6)",
backdropFilter: "blur(14px)",
boxShadow: "0 26px 34px -28px rgba(12, 29, 74, 0.55)",
+ cursor: item.moduleId ? "grab" : "default",
}}
>
{
+
+
+
+
+ Unités de formation
+
+
+ Glissez un chapitre dans une unité pour réorganiser le cours.
+
+
+
+ {unitActionSuccess ? (
+
+ {unitActionSuccess}
+
+ ) : null}
+ {unitActionError ? (
+
+ {unitActionError}
+
+ ) : null}
+ {unitActionLoading ? (
+
+ ) : null}
+
+
+ {unitPanelData.length > 0 ? (
+ unitPanelData.map((unit) => {
+ const normalizedSection =
+ normalizeSectionValue(unit.sectionNumber);
+ const isHovered =
+ hoveredSection === normalizedSection;
+ const canDrop = Boolean(draggingItem?.moduleId);
+ const progressRatio =
+ unit.progress.total > 0
+ ? Math.min(
+ 100,
+ Math.max(
+ 0,
+ (unit.progress.viewed /
+ unit.progress.total) *
+ 100
+ )
+ )
+ : 0;
+ return (
+
+
+
+ {unit.label}
+
+
+
+ {unit.progress.total > 0 ? (
+
+
+
+ {unit.progress.viewed}/{unit.progress.total}{" "}
+ éléments suivis
+
+
+ ) : null}
+ {unit.items.length > 0 ? (
+ 6 ? 228 : "auto",
+ overflowY:
+ unit.items.length > 6
+ ? "auto"
+ : "visible",
+ pr: 0.5,
+ }}
+ >
+ {unit.items.map((item) => {
+ let isViewed = false;
+ if (item.type === "page") {
+ isViewed = Boolean(viewedPages[item.id]);
+ } else if (item.type === "assignment") {
+ isViewed = Boolean(
+ viewedAssignments[item.id]
+ );
+ } else if (item.type === "quiz") {
+ isViewed = Boolean(viewedQuizzes[item.id]);
+ } else if (item.type === "h5p") {
+ isViewed = Boolean(viewedH5P[item.id]);
+ }
+ const isActive =
+ (item.type === "page" &&
+ selectedPage?.id === item.id) ||
+ (item.type === "assignment" &&
+ selectedAssignment?.id === item.id) ||
+ (item.type === "quiz" &&
+ selectedQuiz?.id === item.id) ||
+ (item.type === "h5p" &&
+ selectedH5P?.id === item.id);
+ return (
+
+ openProgramItem(item.id, item.type)
+ }
+ disabled={unitActionLoading}
+ sx={{
+ borderRadius: 3,
+ mb: 0.75,
+ alignItems: "center",
+ bgcolor: isActive
+ ? "rgba(49,83,151,0.14)"
+ : "transparent",
+ color: "rgba(11, 26, 61, 0.82)",
+ boxShadow: isActive
+ ? "0 12px 20px -18px rgba(11,26,61,0.45)"
+ : "none",
+ border: isActive
+ ? "1px solid rgba(49,83,151,0.3)"
+ : "1px solid transparent",
+ transition: "all 0.2s ease",
+ px: 1.75,
+ "&:hover": {
+ bgcolor: "rgba(49,83,151,0.12)",
+ borderColor: "rgba(49,83,151,0.18)",
+ },
+ }}
+ >
+
+ {isViewed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {item.hasMedia ? (
+
+ ) : null}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ Aucun contenu dans cette unité pour le moment.
+
+ )}
+
+ );
+ })
+ ) : (
+
+ Aucune unité détectée pour ce cours pour l’instant.
+
+ )}
+
+
+ Astuce : créez de nouvelles unités depuis la page{" "}
+
+ Gestion des cours
+
+ , puis actualisez cette page pour les retrouver ici.
+
+
+
+
+
diff --git a/frontend/src/components/EditPost.jsx b/frontend/src/components/EditPost.jsx
index f746f0b..589046e 100644
--- a/frontend/src/components/EditPost.jsx
+++ b/frontend/src/components/EditPost.jsx
@@ -17,8 +17,7 @@ import {
import { ArrowBack, Publish, Image as ImageIcon, History } from "@mui/icons-material";
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
-import ReactQuill from "react-quill";
-import "react-quill/dist/quill.snow.css";
+import RichTextEditor from "./common/RichTextEditor";
import "../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
@@ -157,15 +156,13 @@ function EditPost() {
return new File([blob], "image-cloudinary.jpg", { type: blob.type });
};
- const modules = {
- toolbar: [
- [{ header: [1, 2, 3, false] }],
- ["bold", "italic", "underline", "strike"],
- [{ list: "ordered" }, { list: "bullet" }],
- ["link", "image"], // ✅ le bouton image est ici
- ["clean"],
- ],
- };
+ const toolbarOptions = [
+ [{ header: [1, 2, 3, false] }],
+ ["bold", "italic", "underline", "strike"],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["link", "image"],
+ ["clean"],
+ ];
const canSubmit =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
@@ -344,12 +341,11 @@ function EditPost() {
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
-
diff --git a/frontend/src/components/GestionBureauEtudeACF.jsx b/frontend/src/components/GestionBureauEtudeACF.jsx
index 83e20ec..daf4daa 100644
--- a/frontend/src/components/GestionBureauEtudeACF.jsx
+++ b/frontend/src/components/GestionBureauEtudeACF.jsx
@@ -15,8 +15,7 @@ import {
CircularProgress,
} from "@mui/material";
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
-import ReactQuill from "react-quill";
-import "react-quill/dist/quill.snow.css";
+import RichTextEditor from "./common/RichTextEditor";
import "../assets/styleCours.css";
const ETUDE_PAGE_ID = 272;
@@ -408,10 +407,10 @@ function EditPageEtude() {
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
- handleFieldChange("introduction", value)}
- style={{ height: 220 }}
+ minHeight={220}
/>
diff --git a/frontend/src/components/Pages/CreatePost.jsx b/frontend/src/components/Pages/CreatePost.jsx
index ede5a34..7aa9dac 100644
--- a/frontend/src/components/Pages/CreatePost.jsx
+++ b/frontend/src/components/Pages/CreatePost.jsx
@@ -17,8 +17,7 @@ import {
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
-import ReactQuill from "react-quill";
-import "react-quill/dist/quill.snow.css";
+import RichTextEditor from "../common/RichTextEditor";
import "../../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
@@ -272,10 +271,10 @@ function CreatePost() {
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
-
diff --git a/frontend/src/components/Pages/EditPageACF.jsx b/frontend/src/components/Pages/EditPageACF.jsx
index 2fe1cfb..50f350d 100644
--- a/frontend/src/components/Pages/EditPageACF.jsx
+++ b/frontend/src/components/Pages/EditPageACF.jsx
@@ -21,8 +21,7 @@ import {
} from "@mui/material";
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
import CloseIcon from "@mui/icons-material/Close";
-import ReactQuill from "react-quill";
-import "react-quill/dist/quill.snow.css";
+import RichTextEditor from "../common/RichTextEditor";
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
import {
@@ -718,11 +717,11 @@ function EditPageACF() {
)}
{richText ? (
<>
- handleFieldChange(field, content)}
- style={quillStyle}
+ minHeight={220}
+ sx={quillStyle}
/>