feat: integrate Moodle API services, add RichTextEditor component, and configure environment variables for course management.

This commit is contained in:
sebvtl728 2026-04-09 16:37:09 +02:00
parent a264f9b430
commit 3fabebeb85
18 changed files with 2059 additions and 301 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
frontend/.gitignore vendored
View File

@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
# Editor directories and files
.vscode/*

View File

@ -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`.

View File

@ -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",

View File

@ -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",

View File

@ -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 {

View File

@ -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
<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>
)}
{courseFeedback && (
<Alert severity={courseFeedback.type}>{courseFeedback.message}</Alert>
)}
<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
label="Titre du cours"
value={titre}
onChange={(e) => setTitre(e.target.value)}
sx={{ mb: 3 }}
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>
<ReactQuill theme="snow" value={contenu} onChange={setContenu} style={{ height: 200, marginBottom: 30 }} />
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
{loading ? <CircularProgress size={24} /> : "Enregistrer le cours"}
<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

View File

@ -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: [
const toolbarOptions = [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
["link", "image"], // le bouton image est ici
["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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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"

View 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;

View 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);

View 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

Binary file not shown.