feat: integrate Moodle API services, add RichTextEditor component, and configure environment variables for course management.
This commit is contained in:
parent
a264f9b430
commit
3fabebeb85
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@ -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`.
|
||||
|
||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 (
|
||||
<Box sx={{ padding: 4, maxWidth: 800, margin: "auto" }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Ajouter un cours dans Moodle
|
||||
</Typography>
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
mb: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" fontWeight={600}>
|
||||
Gestion des cours et chapitres
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refreshCourses(selectedCourse?.id)}
|
||||
disabled={loadingCourses}
|
||||
variant="outlined"
|
||||
>
|
||||
Actualiser
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography color="text.secondary">
|
||||
Créez, modifiez et supprimez vos cours Moodle directement depuis
|
||||
cette interface. Les chapitres correspondent aux sections du cours.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{success && <Alert severity="success">Cours enregistré avec succès !</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{!isMoodleConfigured() && (
|
||||
<Alert severity="warning">
|
||||
Ajoutez vos identifiants Moodle dans <code>.env.local</code> pour
|
||||
activer cette page.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Titre du cours"
|
||||
value={titre}
|
||||
onChange={(e) => setTitre(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
{courseFeedback && (
|
||||
<Alert severity={courseFeedback.type}>{courseFeedback.message}</Alert>
|
||||
)}
|
||||
|
||||
<ReactQuill theme="snow" value={contenu} onChange={setContenu} style={{ height: 200, marginBottom: 30 }} />
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Liste des cours
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Rechercher un cours"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
{loadingCourses ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{filteredCourses.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="Aucun cours trouvé." />
|
||||
</ListItem>
|
||||
)}
|
||||
{filteredCourses.map((course) => (
|
||||
<ListItem
|
||||
key={course.id}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDeleteCourse(course)}
|
||||
disabled={courseActionLoading}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
bgcolor:
|
||||
selectedCourse?.id === course.id
|
||||
? "action.selected"
|
||||
: "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
primary={course.fullname}
|
||||
secondary={`ID: ${course.id}`}
|
||||
onClick={() => handleSelectCourse(course)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : "Enregistrer le cours"}
|
||||
</Button>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{selectedCourse ? "Modifier le cours" : "Nouveau cours"}
|
||||
</Typography>
|
||||
{selectedCourse && (
|
||||
<Button
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
onClick={handleResetCourseSelection}
|
||||
>
|
||||
Nouveau
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Titre complet"
|
||||
value={courseForm.fullname}
|
||||
onChange={handleCourseInputChange("fullname")}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Identifiant court"
|
||||
value={courseForm.shortname}
|
||||
onChange={handleCourseInputChange("shortname")}
|
||||
helperText="Laissez vide pour générer automatiquement."
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Catégorie"
|
||||
type="number"
|
||||
value={courseForm.categoryid}
|
||||
onChange={handleCourseInputChange("categoryid")}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Résumé du cours
|
||||
</Typography>
|
||||
<RichTextEditor
|
||||
value={courseForm.summary}
|
||||
onChange={handleSummaryChange}
|
||||
minHeight={220}
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreateCourse}
|
||||
disabled={courseActionLoading}
|
||||
>
|
||||
{courseActionLoading && !selectedCourse ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
"Créer"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleUpdateCourse}
|
||||
disabled={!selectedCourse || courseActionLoading}
|
||||
>
|
||||
{courseActionLoading && selectedCourse ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
"Mettre à jour"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={handleDeleteCourse}
|
||||
disabled={!selectedCourse || courseActionLoading}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{selectedCourse && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
Chapitres de « {selectedCourse.fullname} »
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gérez les sections du cours Moodle sélectionné.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refreshSections(selectedCourse.id)}
|
||||
disabled={loadingSections}
|
||||
>
|
||||
Recharger
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{sectionFeedback && (
|
||||
<Alert severity={sectionFeedback.type}>
|
||||
{sectionFeedback.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
{loadingSections ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
maxHeight: 360,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{sectionList.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="Aucun chapitre pour le moment." />
|
||||
</ListItem>
|
||||
)}
|
||||
{sectionList.map((section) => (
|
||||
<ListItem
|
||||
key={`${section.id}-${section.section}`}
|
||||
secondaryAction={
|
||||
<Stack direction="row" spacing={1}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleEditSection(section)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
color="error"
|
||||
onClick={() => handleDeleteSection(section)}
|
||||
disabled={sectionActionLoading}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={section.name}
|
||||
secondary={
|
||||
section.section !== null &&
|
||||
section.section !== undefined
|
||||
? `Position : ${section.section}`
|
||||
: "Position automatique"
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1">
|
||||
{selectedSection
|
||||
? "Modifier le chapitre"
|
||||
: "Nouveau chapitre"}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Titre du chapitre"
|
||||
value={sectionForm.name}
|
||||
onChange={handleSectionInputChange("name")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Position (optionnelle)"
|
||||
type="number"
|
||||
value={sectionForm.section}
|
||||
onChange={handleSectionInputChange("section")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Résumé"
|
||||
value={sectionForm.summary}
|
||||
onChange={handleSectionInputChange("summary")}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
/>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={
|
||||
selectedSection
|
||||
? handleUpdateSection
|
||||
: handleCreateSection
|
||||
}
|
||||
disabled={sectionActionLoading}
|
||||
>
|
||||
{sectionActionLoading ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : selectedSection ? (
|
||||
"Mettre à jour"
|
||||
) : (
|
||||
"Créer"
|
||||
)}
|
||||
</Button>
|
||||
{selectedSection && (
|
||||
<Button onClick={handleResetSectionForm}>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditeurCours;
|
||||
export default GestionCours;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)",
|
||||
}}
|
||||
>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
modules={modules}
|
||||
style={{ height: 280 }}
|
||||
minHeight={280}
|
||||
toolbarOptions={toolbarOptions}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@ -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)",
|
||||
}}
|
||||
>
|
||||
<ReactQuill
|
||||
<RichTextEditor
|
||||
value={acfFields.introduction || ""}
|
||||
onChange={(value) => handleFieldChange("introduction", value)}
|
||||
style={{ height: 220 }}
|
||||
minHeight={220}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -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)",
|
||||
}}
|
||||
>
|
||||
<ReactQuill
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
style={{ height: 280 }}
|
||||
minHeight={280}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
<RichTextEditor
|
||||
value={value || ""}
|
||||
onChange={(content) => handleFieldChange(field, content)}
|
||||
style={quillStyle}
|
||||
minHeight={220}
|
||||
sx={quillStyle}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@ -19,8 +19,7 @@ import {
|
||||
Slider,
|
||||
} from "@mui/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";
|
||||
|
||||
@ -1083,11 +1082,11 @@ const GestionPageAccueil = () => {
|
||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
||||
Texte Construct
|
||||
</Typography>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
<RichTextEditor
|
||||
value={constructText}
|
||||
onChange={setConstructText}
|
||||
style={quillStyle}
|
||||
minHeight={240}
|
||||
sx={quillStyle}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@ -1115,11 +1114,11 @@ const GestionPageAccueil = () => {
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>Notes</Typography>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
<RichTextEditor
|
||||
value={constructNote}
|
||||
onChange={setConstructNote}
|
||||
style={quillStyle}
|
||||
minHeight={220}
|
||||
sx={quillStyle}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@ -1220,11 +1219,11 @@ const GestionPageAccueil = () => {
|
||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
||||
Texte expertise {index}
|
||||
</Typography>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
<RichTextEditor
|
||||
value={textState}
|
||||
onChange={setText}
|
||||
style={quillStyle}
|
||||
minHeight={200}
|
||||
sx={quillStyle}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
119
frontend/src/components/common/RichTextEditor.jsx
Normal file
119
frontend/src/components/common/RichTextEditor.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css";
|
||||
|
||||
const DEFAULT_TOOLBAR = [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["link", "blockquote", "code-block"],
|
||||
["clean"],
|
||||
];
|
||||
|
||||
const RichTextEditor = ({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "Commencez à écrire…",
|
||||
minHeight = 200,
|
||||
toolbarOptions = DEFAULT_TOOLBAR,
|
||||
sx = {},
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const quillRef = useRef(null);
|
||||
const internalUpdateRef = useRef(false);
|
||||
const latestOnChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current || quillRef.current) {
|
||||
return;
|
||||
}
|
||||
const quill = new Quill(containerRef.current, {
|
||||
theme: "snow",
|
||||
placeholder,
|
||||
modules: {
|
||||
toolbar: toolbarOptions,
|
||||
},
|
||||
});
|
||||
quillRef.current = quill;
|
||||
|
||||
const handleTextChange = () => {
|
||||
if (internalUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
const html = quill.root.innerHTML;
|
||||
if (typeof latestOnChangeRef.current === "function") {
|
||||
latestOnChangeRef.current(html);
|
||||
}
|
||||
};
|
||||
|
||||
quill.on("text-change", handleTextChange);
|
||||
|
||||
if (value) {
|
||||
internalUpdateRef.current = true;
|
||||
quill.clipboard.dangerouslyPasteHTML(value);
|
||||
internalUpdateRef.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
quill.off("text-change", handleTextChange);
|
||||
const toolbar = containerRef.current
|
||||
? containerRef.current.previousSibling
|
||||
: null;
|
||||
if (toolbar && toolbar.classList?.contains("ql-toolbar")) {
|
||||
toolbar.remove();
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
quillRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [placeholder]); // initialise une fois (placeholder rarement modifié)
|
||||
|
||||
useEffect(() => {
|
||||
const quill = quillRef.current;
|
||||
if (!quill) {
|
||||
return;
|
||||
}
|
||||
const normalizedValue = value || "";
|
||||
if (normalizedValue === quill.root.innerHTML) {
|
||||
return;
|
||||
}
|
||||
internalUpdateRef.current = true;
|
||||
quill.setContents(quill.clipboard.convert(normalizedValue));
|
||||
internalUpdateRef.current = false;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
"& .ql-container": {
|
||||
borderColor: "rgba(0,0,0,0.12)",
|
||||
minHeight,
|
||||
},
|
||||
"& .ql-toolbar": {
|
||||
borderColor: "rgba(0,0,0,0.12)",
|
||||
},
|
||||
"& .ql-editor": {
|
||||
minHeight,
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
5
frontend/src/config/moodle.js
Normal file
5
frontend/src/config/moodle.js
Normal file
@ -0,0 +1,5 @@
|
||||
export const MOODLE_API_URL = import.meta.env.VITE_MOODLE_API_URL || "";
|
||||
export const MOODLE_TOKEN = import.meta.env.VITE_MOODLE_TOKEN || "";
|
||||
|
||||
export const isMoodleConfigured = () =>
|
||||
Boolean(MOODLE_API_URL && MOODLE_TOKEN);
|
||||
193
frontend/src/services/moodleApi.js
Normal file
193
frontend/src/services/moodleApi.js
Normal file
@ -0,0 +1,193 @@
|
||||
import axios from "axios";
|
||||
import { MOODLE_API_URL, MOODLE_TOKEN } from "../config/moodle";
|
||||
|
||||
const ensureConfig = () => {
|
||||
if (!MOODLE_API_URL || !MOODLE_TOKEN) {
|
||||
throw new Error(
|
||||
"Configuration Moodle manquante. Vérifiez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendParam = (searchParams, value, key) => {
|
||||
if (value === undefined || value === null || key === undefined) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => {
|
||||
appendParam(searchParams, entry, `${key}[${index}]`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object" && !(value instanceof Date)) {
|
||||
Object.entries(value).forEach(([childKey, childValue]) => {
|
||||
const nextKey = key ? `${key}[${childKey}]` : childKey;
|
||||
appendParam(searchParams, childValue, nextKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalizedValue =
|
||||
value instanceof Date ? value.getTime().toString() : String(value);
|
||||
searchParams.append(key, normalizedValue);
|
||||
};
|
||||
|
||||
export const callMoodle = async (wsfunction, payload = {}, options = {}) => {
|
||||
ensureConfig();
|
||||
const params = new URLSearchParams();
|
||||
params.append("wstoken", MOODLE_TOKEN);
|
||||
params.append("wsfunction", wsfunction);
|
||||
params.append("moodlewsrestformat", "json");
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) =>
|
||||
appendParam(params, value, key)
|
||||
);
|
||||
|
||||
const response = await axios.post(MOODLE_API_URL, params, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
timeout: options.timeout ?? 20000,
|
||||
...options.axios,
|
||||
});
|
||||
|
||||
if (response.data?.exception || response.data?.errorcode) {
|
||||
const message =
|
||||
response.data.message ||
|
||||
response.data.debuginfo ||
|
||||
response.data.errorcode;
|
||||
throw new Error(message || "Erreur Moodle inconnue.");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const MoodleApi = {
|
||||
listCourses: (searchValue = "") => {
|
||||
if (searchValue) {
|
||||
return callMoodle("core_course_get_courses_by_field", {
|
||||
field: "search",
|
||||
value: searchValue,
|
||||
}).then((data) => data.courses || []);
|
||||
}
|
||||
return callMoodle("core_course_get_courses").then(
|
||||
(data) => data || []
|
||||
);
|
||||
},
|
||||
getCourseById: async (courseId) => {
|
||||
const response = await callMoodle("core_course_get_courses_by_field", {
|
||||
field: "id",
|
||||
value: Number(courseId),
|
||||
});
|
||||
return response?.courses?.[0] || null;
|
||||
},
|
||||
createCourse: (coursePayload) => {
|
||||
const normalized = {
|
||||
summaryformat: 1,
|
||||
format: "topics",
|
||||
...coursePayload,
|
||||
};
|
||||
if (normalized.categoryid !== undefined) {
|
||||
normalized.categoryid = Number(normalized.categoryid);
|
||||
}
|
||||
return callMoodle("core_course_create_courses", {
|
||||
courses: [normalized],
|
||||
});
|
||||
},
|
||||
updateCourse: (coursePayload) => {
|
||||
const normalized = {
|
||||
summaryformat: 1,
|
||||
...coursePayload,
|
||||
};
|
||||
if (normalized.id !== undefined) {
|
||||
normalized.id = Number(normalized.id);
|
||||
}
|
||||
if (normalized.categoryid !== undefined) {
|
||||
normalized.categoryid = Number(normalized.categoryid);
|
||||
}
|
||||
return callMoodle("core_course_update_courses", {
|
||||
courses: [normalized],
|
||||
});
|
||||
},
|
||||
deleteCourses: (courseIds) =>
|
||||
callMoodle("core_course_delete_courses", {
|
||||
courseids: courseIds,
|
||||
}),
|
||||
getCourseContents: (courseId) =>
|
||||
callMoodle("core_course_get_contents", { courseid: Number(courseId) }),
|
||||
getPagesByCourse: (courseId) =>
|
||||
callMoodle("mod_page_get_pages_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.pages || []),
|
||||
getAssignmentsByCourse: (courseId) =>
|
||||
callMoodle("mod_assign_get_assignments", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => {
|
||||
const normalizedId = Number(courseId);
|
||||
const course = data?.courses?.find((c) => c.id === normalizedId);
|
||||
return course?.assignments || [];
|
||||
}),
|
||||
getH5PActivitiesByCourse: (courseId) =>
|
||||
callMoodle("mod_h5pactivity_get_h5pactivities_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.h5pactivities || []),
|
||||
getQuizzesByCourse: (courseId) =>
|
||||
callMoodle("mod_quiz_get_quizzes_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.quizzes || []),
|
||||
createSection: (sectionPayload) =>
|
||||
callMoodle("core_course_create_sections", {
|
||||
sections: [
|
||||
{
|
||||
summaryformat: 1,
|
||||
...sectionPayload,
|
||||
},
|
||||
],
|
||||
}),
|
||||
updateSection: (sectionPayload) => {
|
||||
const payload = {
|
||||
summaryformat: 1,
|
||||
...sectionPayload,
|
||||
};
|
||||
if (payload.id !== undefined) {
|
||||
payload.id = Number(payload.id);
|
||||
}
|
||||
if (payload.courseid !== undefined) {
|
||||
payload.courseid = Number(payload.courseid);
|
||||
}
|
||||
if (payload.section !== undefined && payload.section !== null) {
|
||||
const normalizedSection = Number(payload.section);
|
||||
payload.section = Number.isNaN(normalizedSection)
|
||||
? undefined
|
||||
: normalizedSection;
|
||||
}
|
||||
return callMoodle("core_course_update_sections", {
|
||||
sections: [payload],
|
||||
});
|
||||
},
|
||||
deleteSections: (courseId, sectionIds, sectionNumbers) => {
|
||||
const payload = {
|
||||
courseid: Number(courseId),
|
||||
};
|
||||
if (sectionIds?.length) {
|
||||
payload.sectionids = sectionIds.map((id) => Number(id));
|
||||
}
|
||||
if (sectionNumbers?.length) {
|
||||
payload.sectionnumbers = sectionNumbers.map((value) => Number(value));
|
||||
}
|
||||
return callMoodle("core_course_delete_sections", payload);
|
||||
},
|
||||
moveModuleToSection: (moduleId, sectionNumber) => {
|
||||
const payload = {
|
||||
cmid: Number(moduleId),
|
||||
};
|
||||
if (sectionNumber !== null && sectionNumber !== undefined) {
|
||||
const normalized = Number(sectionNumber);
|
||||
if (!Number.isNaN(normalized)) {
|
||||
payload.section = normalized;
|
||||
}
|
||||
}
|
||||
return callMoodle("core_course_edit_module", payload);
|
||||
},
|
||||
};
|
||||
|
||||
export default MoodleApi;
|
||||
File diff suppressed because one or more lines are too long
BIN
server/.DS_Store
vendored
BIN
server/.DS_Store
vendored
Binary file not shown.
Loading…
Reference in New Issue
Block a user