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
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.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](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
|
- [@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",
|
"axios": "^1.7.9",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-helmet": "^6.1.0",
|
"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 { useParams } from "react-router-dom";
|
||||||
import axios from "axios";
|
|
||||||
import { Box, Typography, CircularProgress } from "@mui/material";
|
import { Box, Typography, CircularProgress } from "@mui/material";
|
||||||
|
import { MoodleApi } from "../../services/moodleApi";
|
||||||
const API_URL =
|
|
||||||
"https://www.formations.octopusdesign.fr/webservice/rest/server.php";
|
|
||||||
const TOKEN = "685e1b5d794b558b60e971581154c3b2";
|
|
||||||
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
|
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
|
||||||
|
|
||||||
const CoursLecture = () => {
|
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 { token } = useParams();
|
||||||
const [content, setContent] = useState(null);
|
const [content, setContent] = useState(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@ -30,19 +13,34 @@ const CoursLecture = () => {
|
|||||||
|
|
||||||
// ✅ Masquer Header/Footer pendant l'affichage
|
// ✅ Masquer Header/Footer pendant l'affichage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
const header = document.querySelector("header");
|
const header = document.querySelector("header");
|
||||||
const footer = document.querySelector("footer");
|
const footer = document.querySelector("footer");
|
||||||
|
const previousHeaderDisplay = header?.style.display;
|
||||||
|
const previousFooterDisplay = footer?.style.display;
|
||||||
if (header) header.style.display = "none";
|
if (header) header.style.display = "none";
|
||||||
if (footer) footer.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 () => {
|
return () => {
|
||||||
if (header) header.style.display = "";
|
if (header) header.style.display = previousHeaderDisplay || "";
|
||||||
if (footer) footer.style.display = "";
|
if (footer) footer.style.display = previousFooterDisplay || "";
|
||||||
document.removeEventListener("contextmenu", handleContextMenu);
|
document.removeEventListener("contextmenu", handleContextMenu);
|
||||||
document.removeEventListener("copy", handleCopyPaste);
|
document.removeEventListener("copy", handleCopyPaste);
|
||||||
document.removeEventListener("cut", handleCopyPaste);
|
document.removeEventListener("cut", handleCopyPaste);
|
||||||
document.removeEventListener("paste", handleCopyPaste);
|
document.removeEventListener("paste", handleCopyPaste);
|
||||||
document.body.style.userSelect = "auto";
|
document.body.style.userSelect = previousUserSelect || "auto";
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -77,7 +75,8 @@ const CoursLecture = () => {
|
|||||||
linkId,
|
linkId,
|
||||||
format: "json",
|
format: "json",
|
||||||
};
|
};
|
||||||
} catch (jsonError) {
|
} catch (parseError) {
|
||||||
|
console.warn("Lien public JSON invalide :", parseError);
|
||||||
const parts = raw.split("_");
|
const parts = raw.split("_");
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const [courseId, itemId, expiry] = parts;
|
const [courseId, itemId, expiry] = parts;
|
||||||
@ -103,7 +102,8 @@ const CoursLecture = () => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (decodeError) {
|
||||||
|
console.warn("Impossible de décoder le lien sécurisé :", decodeError);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -175,29 +175,9 @@ const CoursLecture = () => {
|
|||||||
|
|
||||||
const fetchPage = async () => {
|
const fetchPage = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(API_URL, {
|
const pages = await MoodleApi.getPagesByCourse(Number(courseId));
|
||||||
params: {
|
const parsedItemId = parseInt(itemId, 10);
|
||||||
wstoken: TOKEN,
|
const found = pages.find((p) => p.id === parsedItemId);
|
||||||
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)
|
|
||||||
);
|
|
||||||
if (found) {
|
if (found) {
|
||||||
setTitle(found.name || "");
|
setTitle(found.name || "");
|
||||||
setContent(found.content || "");
|
setContent(found.content || "");
|
||||||
@ -205,7 +185,8 @@ const CoursLecture = () => {
|
|||||||
setTokenValid(false);
|
setTokenValid(false);
|
||||||
setContent("⛔ Page non trouvée.");
|
setContent("⛔ Page non trouvée.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement de la page Moodle :", error);
|
||||||
setTokenValid(false);
|
setTokenValid(false);
|
||||||
setContent("❌ Erreur lors du chargement.");
|
setContent("❌ Erreur lors du chargement.");
|
||||||
} finally {
|
} finally {
|
||||||
@ -215,19 +196,11 @@ const CoursLecture = () => {
|
|||||||
|
|
||||||
const fetchAssignment = async () => {
|
const fetchAssignment = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(API_URL, {
|
const assignments = await MoodleApi.getAssignmentsByCourse(
|
||||||
params: {
|
Number(courseId)
|
||||||
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 assignment = (course?.assignments || []).find(
|
const parsedAssignmentId = parseInt(itemId, 10);
|
||||||
|
const assignment = assignments.find(
|
||||||
(a) => a.id === parsedAssignmentId
|
(a) => a.id === parsedAssignmentId
|
||||||
);
|
);
|
||||||
if (assignment) {
|
if (assignment) {
|
||||||
@ -238,6 +211,10 @@ const CoursLecture = () => {
|
|||||||
setContent("⛔ Devoir introuvable.");
|
setContent("⛔ Devoir introuvable.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Erreur lors du chargement du devoir Moodle :",
|
||||||
|
error
|
||||||
|
);
|
||||||
setTokenValid(false);
|
setTokenValid(false);
|
||||||
setContent("❌ Erreur lors du chargement du devoir.");
|
setContent("❌ Erreur lors du chargement du devoir.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,80 +1,817 @@
|
|||||||
import React, { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ReactQuill from "react-quill";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import "react-quill/dist/quill.snow.css";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import axios from "axios";
|
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 defaultCourseForm = {
|
||||||
const TOKEN = "685e1b5d794b558b60e971581154c3b2";
|
fullname: "",
|
||||||
|
shortname: "",
|
||||||
|
categoryid: "1",
|
||||||
|
summary: "",
|
||||||
|
};
|
||||||
|
|
||||||
const EditeurCours = () => {
|
const defaultSectionForm = {
|
||||||
const [titre, setTitre] = useState("");
|
name: "",
|
||||||
const [contenu, setContenu] = useState("");
|
summary: "",
|
||||||
const [loading, setLoading] = useState(false);
|
section: "",
|
||||||
const [success, setSuccess] = useState(false);
|
};
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const formatSectionName = (section) => {
|
||||||
if (!titre || !contenu) return;
|
if (!section) {
|
||||||
setLoading(true);
|
return "Section sans titre";
|
||||||
setSuccess(false);
|
}
|
||||||
setError("");
|
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();
|
const mapCourseToForm = (course) => ({
|
||||||
params.append("wstoken", TOKEN);
|
fullname: course.fullname || "",
|
||||||
params.append("wsfunction", "core_course_create_courses");
|
shortname: course.shortname || "",
|
||||||
params.append("moodlewsrestformat", "json");
|
categoryid: String(course.categoryid || "1"),
|
||||||
|
summary: course.summary || "",
|
||||||
|
});
|
||||||
|
|
||||||
params.append("courses[0][fullname]", titre);
|
const GestionCours = () => {
|
||||||
params.append("courses[0][shortname]", titre.replace(/\s+/g, "_").toLowerCase());
|
const [courses, setCourses] = useState([]);
|
||||||
params.append("courses[0][categoryid]", 1);
|
const [courseForm, setCourseForm] = useState(defaultCourseForm);
|
||||||
params.append("courses[0][summary]", contenu);
|
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||||
params.append("courses[0][summaryformat]", 1);
|
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 {
|
try {
|
||||||
await axios.post(API_URL, params);
|
if (!isMoodleConfigured()) {
|
||||||
setSuccess(true);
|
return;
|
||||||
setTitre("");
|
}
|
||||||
setContenu("");
|
setLoadingCourses(true);
|
||||||
} catch (err) {
|
const data = await MoodleApi.listCourses();
|
||||||
setError("Erreur lors de la création du cours.");
|
const sanitized = (Array.isArray(data) ? data : []).filter(
|
||||||
console.error(err);
|
(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 {
|
} 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 (
|
return (
|
||||||
<Box sx={{ padding: 4, maxWidth: 800, margin: "auto" }}>
|
<Box sx={{ p: 4 }}>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Stack spacing={3}>
|
||||||
Ajouter un cours dans Moodle
|
<Box>
|
||||||
</Typography>
|
<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>}
|
{!isMoodleConfigured() && (
|
||||||
{error && <Alert severity="error">{error}</Alert>}
|
<Alert severity="warning">
|
||||||
|
Ajoutez vos identifiants Moodle dans <code>.env.local</code> pour
|
||||||
|
activer cette page.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextField
|
{courseFeedback && (
|
||||||
fullWidth
|
<Alert severity={courseFeedback.type}>{courseFeedback.message}</Alert>
|
||||||
label="Titre du cours"
|
)}
|
||||||
value={titre}
|
|
||||||
onChange={(e) => setTitre(e.target.value)}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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}>
|
<Grid item xs={12} md={8}>
|
||||||
{loading ? <CircularProgress size={24} /> : "Enregistrer le cours"}
|
<Card>
|
||||||
</Button>
|
<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>
|
</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 { ArrowBack, Publish, Image as ImageIcon, History } from "@mui/icons-material";
|
||||||
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
|
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
|
||||||
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
|
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
|
||||||
import ReactQuill from "react-quill";
|
import RichTextEditor from "./common/RichTextEditor";
|
||||||
import "react-quill/dist/quill.snow.css";
|
|
||||||
import "../assets/styleCours.css";
|
import "../assets/styleCours.css";
|
||||||
|
|
||||||
const BUTTON_BASE_SX = Object.freeze({
|
const BUTTON_BASE_SX = Object.freeze({
|
||||||
@ -157,15 +156,13 @@ function EditPost() {
|
|||||||
return new File([blob], "image-cloudinary.jpg", { type: blob.type });
|
return new File([blob], "image-cloudinary.jpg", { type: blob.type });
|
||||||
};
|
};
|
||||||
|
|
||||||
const modules = {
|
const toolbarOptions = [
|
||||||
toolbar: [
|
[{ header: [1, 2, 3, false] }],
|
||||||
[{ header: [1, 2, 3, false] }],
|
["bold", "italic", "underline", "strike"],
|
||||||
["bold", "italic", "underline", "strike"],
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
["link", "image"],
|
||||||
["link", "image"], // ✅ le bouton image est ici
|
["clean"],
|
||||||
["clean"],
|
];
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
|
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
|
||||||
@ -344,12 +341,11 @@ function EditPost() {
|
|||||||
backgroundColor: "rgba(255,255,255,0.92)",
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
theme="snow"
|
|
||||||
value={content}
|
value={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
modules={modules}
|
minHeight={280}
|
||||||
style={{ height: 280 }}
|
toolbarOptions={toolbarOptions}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,7 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
|
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
|
||||||
import ReactQuill from "react-quill";
|
import RichTextEditor from "./common/RichTextEditor";
|
||||||
import "react-quill/dist/quill.snow.css";
|
|
||||||
import "../assets/styleCours.css";
|
import "../assets/styleCours.css";
|
||||||
|
|
||||||
const ETUDE_PAGE_ID = 272;
|
const ETUDE_PAGE_ID = 272;
|
||||||
@ -408,10 +407,10 @@ function EditPageEtude() {
|
|||||||
backgroundColor: "rgba(255,255,255,0.92)",
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
value={acfFields.introduction || ""}
|
value={acfFields.introduction || ""}
|
||||||
onChange={(value) => handleFieldChange("introduction", value)}
|
onChange={(value) => handleFieldChange("introduction", value)}
|
||||||
style={{ height: 220 }}
|
minHeight={220}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -17,8 +17,7 @@ import {
|
|||||||
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
|
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
|
||||||
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||||
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||||
import ReactQuill from "react-quill";
|
import RichTextEditor from "../common/RichTextEditor";
|
||||||
import "react-quill/dist/quill.snow.css";
|
|
||||||
import "../../assets/styleCours.css";
|
import "../../assets/styleCours.css";
|
||||||
|
|
||||||
const BUTTON_BASE_SX = Object.freeze({
|
const BUTTON_BASE_SX = Object.freeze({
|
||||||
@ -272,10 +271,10 @@ function CreatePost() {
|
|||||||
backgroundColor: "rgba(255,255,255,0.92)",
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
value={content}
|
value={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
style={{ height: 280 }}
|
minHeight={280}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
|
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import ReactQuill from "react-quill";
|
import RichTextEditor from "../common/RichTextEditor";
|
||||||
import "react-quill/dist/quill.snow.css";
|
|
||||||
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||||
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||||
import {
|
import {
|
||||||
@ -718,11 +717,11 @@ function EditPageACF() {
|
|||||||
)}
|
)}
|
||||||
{richText ? (
|
{richText ? (
|
||||||
<>
|
<>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
theme="snow"
|
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(content) => handleFieldChange(field, content)}
|
onChange={(content) => handleFieldChange(field, content)}
|
||||||
style={quillStyle}
|
minHeight={220}
|
||||||
|
sx={quillStyle}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -19,8 +19,7 @@ import {
|
|||||||
Slider,
|
Slider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import ReactQuill from "react-quill";
|
import RichTextEditor from "../common/RichTextEditor";
|
||||||
import "react-quill/dist/quill.snow.css";
|
|
||||||
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||||
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||||
|
|
||||||
@ -1083,11 +1082,11 @@ const GestionPageAccueil = () => {
|
|||||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
||||||
Texte Construct
|
Texte Construct
|
||||||
</Typography>
|
</Typography>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
theme="snow"
|
|
||||||
value={constructText}
|
value={constructText}
|
||||||
onChange={setConstructText}
|
onChange={setConstructText}
|
||||||
style={quillStyle}
|
minHeight={240}
|
||||||
|
sx={quillStyle}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -1115,11 +1114,11 @@ const GestionPageAccueil = () => {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>Notes</Typography>
|
<Typography sx={{ mb: 1, fontWeight: "bold" }}>Notes</Typography>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
theme="snow"
|
|
||||||
value={constructNote}
|
value={constructNote}
|
||||||
onChange={setConstructNote}
|
onChange={setConstructNote}
|
||||||
style={quillStyle}
|
minHeight={220}
|
||||||
|
sx={quillStyle}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -1220,11 +1219,11 @@ const GestionPageAccueil = () => {
|
|||||||
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
<Typography sx={{ mb: 1, fontWeight: "bold" }}>
|
||||||
Texte expertise {index}
|
Texte expertise {index}
|
||||||
</Typography>
|
</Typography>
|
||||||
<ReactQuill
|
<RichTextEditor
|
||||||
theme="snow"
|
|
||||||
value={textState}
|
value={textState}
|
||||||
onChange={setText}
|
onChange={setText}
|
||||||
style={quillStyle}
|
minHeight={200}
|
||||||
|
sx={quillStyle}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
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