diff --git a/.DS_Store b/.DS_Store index 1e7e613..4eeb8ae 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index 9c2b6b4..d75e959 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ # Octopus-React-Wp +## Formulaire de contact – mise en route + +Le formulaire (`frontend/src/components/Pages/Contact.jsx`) envoie désormais une requête POST vers l’API de contact. +Suivez ces étapes pour activer l’envoi d’e-mails : + +1. **Configuration backend** + - Copiez `server/cloudinary-backend/.env.example` vers `server/cloudinary-backend/.env`. + - Renseignez vos identifiants SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, etc.). + - Facultatif : ajustez `CONTACT_RECIPIENT`, `CONTACT_FROM`, ou ajoutez des domaines supplémentaires dans `CORS_ALLOWED_ORIGINS`. + +2. **Installer et lancer le serveur contact** + ```bash + cd server/cloudinary-backend + npm install + npm start + ``` + Par défaut, l’API est disponible sur `http://localhost:3001/api/contact`. + +3. **Configuration du front-end** + - Copiez `frontend/.env.local.example` vers `frontend/.env.local`. + - Ajustez `VITE_CONTACT_API_URL` si nécessaire (par défaut `http://localhost:3001/api/contact` en dev). + - Relancez l’application React : + ```bash + cd frontend + npm run dev + ``` + +En production, assurez-vous que l’URL définie dans `VITE_CONTACT_API_URL` pointe vers votre API accessible publiquement (ex. `https://octopusdesign.fr/api/contact`) et que le serveur backend est déployé avec les mêmes variables d’environnement SMTP. diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..6a7becd --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,3 @@ +# URL of the contact API (backend server). +# In development, point to the local Express server. +VITE_CONTACT_API_URL=http://localhost:3001/api/contact diff --git a/frontend/src/assets/styleCours.css b/frontend/src/assets/styleCours.css index 66edccf..b9b8fff 100644 --- a/frontend/src/assets/styleCours.css +++ b/frontend/src/assets/styleCours.css @@ -119,4 +119,256 @@ ul li { .c4lv-readingcontext { padding: 30px 40px 32px 40px; font-family: sans-serif; -} \ No newline at end of file +} + +/* Liquid glass dashboard */ +.glass-dashboard { + position: relative; + overflow: hidden; + background: + radial-gradient(180px at 4% 2%, rgba(123, 192, 255, 0.35), transparent 65%), + radial-gradient(220px at 94% 8%, rgba(255, 180, 250, 0.32), transparent 70%), + radial-gradient(280px at 50% 100%, rgba(102, 204, 255, 0.22), transparent 72%), + linear-gradient(135deg, #0b1a3d 0%, #1e2c58 45%, #5a3fb6 100%); +} + +.glass-orb { + position: absolute; + opacity: 0.58; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + pointer-events: none; + filter: blur(0); + mix-blend-mode: screen; +} + +.glass-orb.orb-1 { + width: 420px; + height: 420px; + top: -180px; + left: -120px; + background: rgba(118, 161, 255, 0.45); +} + +.glass-orb.orb-2 { + width: 320px; + height: 320px; + top: 22%; + right: -140px; + background: rgba(255, 172, 236, 0.36); +} + +.glass-orb.orb-3 { + width: 460px; + height: 460px; + bottom: -220px; + left: 32%; + background: rgba(125, 215, 255, 0.28); +} + +.glass-panel { + background: rgba(255, 255, 255, 0.22); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.35); + box-shadow: 0 32px 48px -28px rgba(14, 21, 52, 0.68); +} + +.hero-panel { + background: rgba(255, 255, 255, 0.14); +} + +.glass-card { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.16), + rgba(255, 255, 255, 0.08) + ) !important; + backdrop-filter: blur(22px) !important; + -webkit-backdrop-filter: blur(22px) !important; + border: 1px solid rgba(255, 255, 255, 0.28) !important; + box-shadow: 0 28px 40px -32px rgba(12, 29, 74, 0.48) !important; + position: relative; + overflow: hidden; + transition: box-shadow 0.3s ease, border-color 0.3s ease, + background 0.3s ease; +} + +.glass-card::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient( + circle at 20% 20%, + rgba(255, 255, 255, 0.28), + transparent 55% + ); + opacity: 0.6; + pointer-events: none; +} + +.glass-card:hover { + box-shadow: 0 36px 50px -28px rgba(12, 29, 74, 0.56) !important; + border-color: rgba(49, 83, 151, 0.35) !important; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.22), + rgba(255, 255, 255, 0.12) + ) !important; +} + +.glass-subcard { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.6), + rgba(255, 255, 255, 0.42) + ) !important; + backdrop-filter: blur(20px) !important; + -webkit-backdrop-filter: blur(20px) !important; + border: 1px solid rgba(255, 255, 255, 0.35) !important; + box-shadow: 0 32px 46px -30px rgba(12, 29, 74, 0.5) !important; + position: relative; + overflow: hidden; +} + +.glass-subcard::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient( + 320px at 18% 30%, + rgba(123, 192, 255, 0.12), + transparent 70% + ), + radial-gradient( + 320px at 82% 12%, + rgba(255, 180, 250, 0.1), + transparent 68% + ); + pointer-events: none; + opacity: 0.6; +} + +.course-modal { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.course-modal__hero { + position: relative; + overflow: hidden; +} + +.course-modal__hero::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 260px at 8% 18%, + rgba(255, 255, 255, 0.35), + transparent 65% + ), + radial-gradient( + 320px at 92% 12%, + rgba(250, 233, 255, 0.3), + transparent 70% + ); + opacity: 0.35; + pointer-events: none; +} + +.course-modal__hero-glow { + position: absolute; + width: 420px; + height: 420px; + right: -120px; + bottom: -140px; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.55) 0%, + rgba(255, 255, 255, 0) 70% + ); + pointer-events: none; +} + +.course-modal__close { + box-shadow: 0 16px 24px -18px rgba(0, 0, 0, 0.45); +} + +.course-modal__steps { + position: relative; +} + +.course-modal__step-card { + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.course-modal__step-card:hover { + transform: translateY(-4px); + box-shadow: 0 32px 36px -28px rgba(12, 29, 74, 0.55); +} + +.course-modal__filters { + position: relative; + overflow: hidden; +} + +.course-modal__filters::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(123, 192, 255, 0.12), + rgba(255, 180, 250, 0.08) + ); + pointer-events: none; +} + +.course-modal__section-header { + position: relative; +} + +.glass-article-card { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.68), + rgba(255, 255, 255, 0.5) + ) !important; + backdrop-filter: blur(24px) !important; + -webkit-backdrop-filter: blur(24px) !important; + border: 1px solid rgba(255, 255, 255, 0.4) !important; + box-shadow: 0 36px 50px -30px rgba(12, 29, 74, 0.54) !important; + position: relative; + overflow: hidden; + transition: transform 0.35s ease, box-shadow 0.35s ease, + border-color 0.35s ease; + will-change: transform, box-shadow; +} + +.glass-article-card::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient( + 480px at 20% 10%, + rgba(255, 255, 255, 0.32), + transparent 65% + ), + radial-gradient( + 360px at 82% 12%, + rgba(255, 214, 150, 0.2), + transparent 70% + ); + opacity: 0.6; + pointer-events: none; +} + +.glass-article-card:hover { + transform: translate3d(0, -6px, 0); + box-shadow: 0 44px 56px -30px rgba(12, 29, 74, 0.62); + border-color: rgba(49, 83, 151, 0.42); + z-index: 2; +} diff --git a/frontend/src/components/Admin/ListeCours.jsx b/frontend/src/components/Admin/ListeCours.jsx index a147405..f3e4bcf 100644 --- a/frontend/src/components/Admin/ListeCours.jsx +++ b/frontend/src/components/Admin/ListeCours.jsx @@ -26,11 +26,13 @@ import { InputLabel, Select, MenuItem, + IconButton, } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; import DoneAllIcon from "@mui/icons-material/DoneAll"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import CloseIcon from "@mui/icons-material/Close"; import axios from "axios"; import { useNavigate } from "react-router-dom"; import { getToken } from "../../auth"; @@ -47,6 +49,148 @@ const ITEM_TYPE_META = { h5p: { label: "Activité H5P", chipColor: "info" }, }; +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.2, + py: 0.65, + fontSize: "0.78rem", + minHeight: 32, + }, + medium: { + px: 2.9, + py: 0.85, + fontSize: "0.86rem", + minHeight: 38, + }, + large: { + px: 3.6, + py: 1.05, + fontSize: "0.94rem", + minHeight: 44, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.6)", + border: "1px solid rgba(255,255,255,0.35)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 24px 34px -18px rgba(11, 26, 61, 0.62)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.7)", + background: + "linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))", + boxShadow: "none", + border: "1px solid rgba(49,83,151,0.18)", + }, + }, + secondary: { + background: "rgba(49,83,151,0.12)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.2)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(49,83,151,0.18)", + borderColor: "rgba(49,83,151,0.35)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + "&.Mui-disabled": { + color: "rgba(49,83,151,0.45)", + borderColor: "rgba(49,83,151,0.12)", + background: "rgba(49,83,151,0.08)", + }, + }, + outline: { + background: "rgba(255,255,255,0.78)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.35)", + "&:hover": { + background: "rgba(255,255,255,0.92)", + borderColor: "rgba(49,83,151,0.55)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.45)", + }, + "&.Mui-disabled": { + color: "rgba(49,83,151,0.4)", + borderColor: "rgba(49,83,151,0.18)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.14)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.24)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.22)", + borderColor: "rgba(255,255,255,0.35)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.55)", + borderColor: "rgba(255,255,255,0.18)", + }, + }, + success: { + background: "linear-gradient(135deg, #2dc897, #6ef7c4)", + color: "#0f2c36", + border: "1px solid rgba(45,200,151,0.35)", + boxShadow: "0 18px 28px -18px rgba(26, 87, 76, 0.42)", + "&:hover": { + background: "linear-gradient(135deg, #27b486, #60e0b0)", + boxShadow: "0 24px 34px -18px rgba(26, 87, 76, 0.48)", + }, + "&.Mui-disabled": { + color: "rgba(15,44,54,0.48)", + background: "rgba(45,200,151,0.28)", + boxShadow: "none", + border: "1px solid rgba(45,200,151,0.18)", + }, + }, + danger: { + background: "linear-gradient(135deg, #f05b6b, #ff8a80)", + color: "#3a0a0f", + border: "1px solid rgba(240,91,107,0.35)", + boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)", + "&:hover": { + background: "linear-gradient(135deg, #e34659, #ff7575)", + boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)", + }, + "&.Mui-disabled": { + color: "rgba(58,10,15,0.5)", + background: "rgba(240,91,107,0.28)", + border: "1px solid rgba(240,91,107,0.18)", + boxShadow: "none", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); + const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks"; const DEFAULT_LINK_DURATION_HOURS = 24; const LINK_DURATION_PRESETS = [ @@ -86,6 +230,7 @@ const ListeCours = () => { const [publicLinks, setPublicLinks] = useState({}); const [linkDurationSelections, setLinkDurationSelections] = useState({}); const [progressByCourse, setProgressByCourse] = useState({}); + const [courseSearch, setCourseSearch] = useState(""); useEffect(() => { if (typeof window === "undefined") { return; @@ -742,26 +887,145 @@ const ListeCours = () => { fetchAssignments(cours.id); }; + const handleCloseModal = React.useCallback(() => { + setSelected(null); + setPages([]); + setSectionNames({}); + setSelectedSection(null); + setH5pActivities([]); + setSelectedH5P(null); + setModuleSections({}); + setAssignments([]); + setAssignmentsRaw([]); + setQuizzes([]); + setSearchQuery(""); + setShowOnlyMedia(false); + }, []); + const buildPdfContainer = (titleText, htmlContent) => { - const now = new Date().toLocaleDateString(); + const now = new Date().toLocaleDateString("fr-FR"); + const parser = new DOMParser(); + const parsed = parser.parseFromString( + `
${htmlContent || ""}
`, + "text/html" + ); + const content = parsed.body; + + content.querySelectorAll("script, style").forEach((node) => node.remove()); + content.querySelectorAll("iframe").forEach((iframe) => { + const link = document.createElement("p"); + link.innerHTML = `Média externe : ${iframe.src}`; + iframe.replaceWith(link); + }); + content.querySelectorAll("video, audio").forEach((media) => { + const info = document.createElement("p"); + info.innerHTML = "Média intégré : disponible uniquement en ligne."; + media.replaceWith(info); + }); + content.querySelectorAll("img").forEach((img) => { + img.removeAttribute("height"); + img.removeAttribute("width"); + img.style.maxWidth = "100%"; + img.style.height = "auto"; + img.style.display = "block"; + img.style.margin = "12px auto"; + const parent = img.parentElement; + if (parent && parent.tagName.toLowerCase() === "p") { + parent.style.textAlign = "center"; + } + }); + content.querySelectorAll("table").forEach((table) => { + table.style.width = "100%"; + table.style.borderCollapse = "collapse"; + table.style.margin = "16px 0"; + table.querySelectorAll("td, th").forEach((cell) => { + cell.style.border = "1px solid #ccd3e0"; + cell.style.padding = "8px 10px"; + }); + }); + const container = document.createElement("div"); - container.style.padding = "20px"; - container.style.fontFamily = "Nunito, sans-serif"; - container.style.width = "100%"; - container.style.maxWidth = "800px"; - container.style.boxSizing = "border-box"; - container.style.overflowWrap = "break-word"; - - const title = document.createElement("h3"); - title.textContent = titleText; - container.appendChild(title); - - const body = document.createElement("div"); - body.innerHTML = htmlContent; - container.appendChild(body); + container.className = "pdf-export-container"; + container.appendChild(content); const style = document.createElement("style"); - style.textContent = `img { max-width: 100%; height: auto; } h1, h2, h3, h4, p { page-break-inside: avoid; }`; + style.textContent = ` + @page { + size: A4 portrait; + margin: 18mm; + } + * { + box-sizing: border-box; + } + body { + font-family: "Nunito", "Helvetica Neue", Arial, sans-serif; + color: #0b1a3d; + } + .pdf-export-container { + width: 100%; + max-width: 720px; + padding: 0 24px 24px; + font-family: "Nunito", "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + line-height: 1.6; + color: #0b1a3d; + } + .pdf-export-container h1, + .pdf-export-container h2, + .pdf-export-container h3, + .pdf-export-container h4 { + color: #315397; + font-weight: 700; + margin: 18px 0 12px; + page-break-after: avoid; + } + .pdf-export-container p { + margin: 12px 0; + page-break-inside: avoid; + } + .pdf-export-container ul, + .pdf-export-container ol { + margin: 12px 0 12px 18px; + padding: 0; + } + .pdf-export-container li { + margin-bottom: 6px; + } + .pdf-export-container blockquote { + border-left: 4px solid #315397; + padding-left: 12px; + margin: 16px 0; + font-style: italic; + background: rgba(49, 83, 151, 0.06); + } + .pdf-export-container a { + color: #315397; + text-decoration: underline; + word-break: break-word; + } + .pdf-header { + text-align: center; + margin-bottom: 24px; + } + .pdf-header h2 { + font-size: 22px; + margin: 0; + } + .pdf-header p { + font-size: 12px; + color: #5b6b91; + margin: 4px 0 0; + } + `; + + const header = document.createElement("div"); + header.className = "pdf-header"; + header.innerHTML = ` +

${titleText || "Cours Octopus"}

+

Exporté le ${now}

+ `; + + container.prepend(header); container.appendChild(style); return { container, now }; @@ -770,11 +1034,11 @@ const ListeCours = () => { const exportPagePDF = (page) => { const { container, now } = buildPdfContainer(page.name, page.content); const opt = { - margin: 0.5, + margin: [18, 15, 20, 15], filename: `${page.name}_${now}.pdf`, - image: { type: "jpeg", quality: 0.98 }, - html2canvas: { scale: 2, useCORS: true }, - jsPDF: { unit: "in", format: "letter", orientation: "portrait" }, + image: { type: "jpeg", quality: 0.95 }, + html2canvas: { scale: 2, useCORS: true, scrollX: 0, scrollY: 0 }, + jsPDF: { unit: "mm", format: "a4", orientation: "portrait" }, }; window.html2pdf().set(opt).from(container).save(); }; @@ -786,11 +1050,11 @@ const ListeCours = () => { htmlContent ); const opt = { - margin: 0.5, + margin: [18, 15, 20, 15], filename: `${assignment.name}_${now}.pdf`, - image: { type: "jpeg", quality: 0.98 }, - html2canvas: { scale: 2, useCORS: true }, - jsPDF: { unit: "in", format: "letter", orientation: "portrait" }, + image: { type: "jpeg", quality: 0.95 }, + html2canvas: { scale: 2, useCORS: true, scrollX: 0, scrollY: 0 }, + jsPDF: { unit: "mm", format: "a4", orientation: "portrait" }, }; window.html2pdf().set(opt).from(container).save(); }; @@ -1166,6 +1430,39 @@ const ListeCours = () => { buildProgressKey, ]); + const courseStats = React.useMemo(() => { + const total = cours.length; + if (total === 0) { + return { total: 0, completed: 0, avgProgress: 0 }; + } + let completed = 0; + let progressSum = 0; + cours.forEach((course) => { + const ratio = progressByCourse[course.id]?.progressRatio ?? 0; + progressSum += ratio; + if (ratio >= 0.999) { + completed += 1; + } + }); + return { + total, + completed, + avgProgress: progressSum / total, + }; + }, [cours, progressByCourse]); + + const filteredCourses = React.useMemo(() => { + const query = courseSearch.trim().toLowerCase(); + if (!query) { + return cours; + } + return cours.filter((course) => + course.fullname?.toLowerCase?.().includes(query) + ); + }, [cours, courseSearch]); + + const filteredCoursesCount = filteredCourses.length; + const totalProgramItems = programItems.length; const filteredProgramCount = filteredProgramItems.length; const filteredLabel = @@ -1196,6 +1493,30 @@ const ListeCours = () => { const programmeInfoMessage = "Visualisez les cours, devoirs, tests et activités interactives depuis un seul espace."; + const modalSteps = React.useMemo( + () => [ + { + id: "01", + title: "Explorer les chapitres", + description: + "Consultez l’ensemble des contenus du parcours : cours, devoirs, tests et activités interactives.", + }, + { + id: "02", + title: "Affiner les résultats", + description: + "Filtrez par chapitre, recherchez un mot-clé ou limitez-vous aux cours riches en médias.", + }, + { + id: "03", + title: "Ouvrir un contenu", + description: + "Sélectionnez un programme pour consulter le contenu, générer un lien public ou exporter en PDF.", + }, + ], + [] + ); + const handleItemTitleClick = React.useCallback( (item) => { if (!item) { @@ -1246,178 +1567,633 @@ const ListeCours = () => { ); return ( - - - - Cours - - {loading ? ( - - - - ) : ( - - {cours.map((course) => { - const courseProgress = progressByCourse[course.id]; - const progressRatio = courseProgress?.progressRatio ?? null; - const progressLabel = courseProgress - ? `${courseProgress.viewedItems}/${courseProgress.totalItems} éléments` - : null; - return ( - - handleSelect(course)} + + + + + + + + + + + + + + Espace pédagogique + + + Explorer vos cours Octopus + + + Accédez aux contenus, devoirs, tests et activités interactives + dans une interface fluide. Filtrez vos programmes et reprenez + exactement là où vous vous êtes arrêté. + + + + + + Progression moyenne des cours + + + + - - - {course.fullname} - - {progressRatio !== null ? ( - - - {progressLabel ? ( - - {progressLabel} - + {`${courseStats.completed}/${courseStats.total} cours terminés`} + + + {`${Math.round(courseStats.avgProgress * 100)}%`} + + + + + + + + + + + + + setCourseSearch(event.target.value)} + InputProps={{ + sx: { + borderRadius: 3, + bgcolor: "rgba(255,255,255,0.72)", + backdropFilter: "blur(8px)", + }, + }} + /> + + Astuce : ouvrez un cours pour accéder aux chapitres, créer des liens + de partage temporaires et suivre votre progression. + + + + {loading ? ( + + + + ) : filteredCoursesCount > 0 ? ( + + {filteredCourses.map((course) => { + const courseProgress = progressByCourse[course.id]; + const progressRatio = courseProgress?.progressRatio ?? 0; + const progressLabel = courseProgress + ? `${courseProgress.viewedItems}/${courseProgress.totalItems} éléments` + : "Progression en attente"; + const completionPercent = Math.min( + 100, + Math.max(0, progressRatio * 100) + ); + return ( + + handleSelect(course)} + sx={{ + cursor: "pointer", + borderRadius: 4, + px: 2, + py: 3, + display: "flex", + flexDirection: "column", + gap: 2, + position: "relative", + overflow: "hidden", + transition: + "transform 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease", + background: "rgba(255,255,255,0.26)", + border: "1px solid rgba(255,255,255,0.35)", + backdropFilter: "blur(18px)", + boxShadow: + "0 28px 36px -24px rgba(8, 22, 68, 0.78)", + "&:hover": { + transform: "translateY(-6px)", + borderColor: "rgba(255,255,255,0.7)", + boxShadow: + "0 36px 48px -22px rgba(9, 29, 92, 0.86)", + }, + }} + > + + + + {course.fullname} + + {progressRatio >= 0.999 ? ( + ) : null} + + + {course.shortname || "Programme Octopus"} + + + + + {progressLabel} + - ) : null} - - - - ); - })} - - )} + + + + + ); + })} + + ) : ( + + Aucun cours ne correspond à votre recherche. Essayez un autre mot-clé. + + )} + { - setSelected(null); - setPages([]); - setSectionNames({}); - setSelectedSection(null); - setH5pActivities([]); - setSelectedH5P(null); - setModuleSections({}); - setAssignments([]); - setAssignmentsRaw([]); - setQuizzes([]); - setSearchQuery(""); - setShowOnlyMedia(false); - }} + onClose={handleCloseModal} closeAfterTransition BackdropComponent={Backdrop} - BackdropProps={{ timeout: 300 }} + BackdropProps={{ + timeout: 300, + sx: { + backdropFilter: "blur(12px)", + backgroundColor: "rgba(6, 18, 38, 0.55)", + }, + }} > - + + + + + - {selected?.fullname} - - - - - - + - - {overallStats.viewedItems}/{overallStats.totalItems} éléments - - - {overallStats.completedChapters}/{overallStats.totalChapters} chapitres - - + Parcours sélectionné + + + {selected?.fullname} + + + Retrouvez tous les contenus du parcours et reprenez le fil de + votre apprentissage en un seul coup d’œil. + - - - - - - 1. Explorer les Programmes - - - Accédez aux cours, devoirs, tests et activités interactives liés - à ce parcours. - + + + + + + + + + {overallStats.viewedItems}/{overallStats.totalItems} éléments + + + {Math.round(overallStats.progressRatio * 100)}% + + + + + + - - + {modalSteps.map((step) => ( + + + {step.id} + + + {step.title} + + + {step.description} + + + ))} + + + + - 2. Affiner les résultats - + + + Affiner votre sélection + + + Combinez les filtres pour accéder rapidement au chapitre ou au + contenu recherché. + + + { if (!option || typeof option === "string") { @@ -1485,7 +2261,13 @@ const ListeCours = () => { { {hasPageItems ? ( { label={`Total ${totalProgramItems}`} /> - + {programmeInfoMessage} - - 3. Sélectionner un programme - + + Programmes du cours + + + Cliquez sur un programme pour afficher son contenu détaillé. + + {loadingPages ? ( @@ -1604,7 +2413,19 @@ const ListeCours = () => { ); return ( - + { > {sectionLabel} @@ -1708,6 +2533,7 @@ const ListeCours = () => { @@ -1914,6 +2751,12 @@ const ListeCours = () => { BackdropProps={{ timeout: 300, invisible: previewHasH5P || Boolean(selectedQuiz), + sx: previewHasH5P || Boolean(selectedQuiz) + ? {} + : { + backdropFilter: "blur(10px)", + backgroundColor: "rgba(6, 18, 38, 0.45)", + }, }} > { left: "8%", width: "80%", height: "100vh", - bgcolor: "background.paper", - p: 4, + background: "rgba(255,255,255,0.95)", + backdropFilter: "blur(14px)", + p: { xs: 3, md: 4 }, overflowY: "auto", fontFamily: "Nunito, sans-serif", - color: "#315397", + color: "#0b1a3d", + boxShadow: "0 44px 60px -45px rgba(7, 16, 45, 0.65)", + borderRadius: { xs: 0, md: 4 }, }} > { selectedQuiz?.name} - - - Modifier l'article - - -
- setTitle(e.target.value)} - sx={{ mb: 2 }} - /> + + + Publication Octopus + + + Modifier l’article + - Contenu de l'article : + Ajustez votre contenu, remplacez le visuel mis en avant et publiez + les mises à jour directement sur le site Octopus. + + - - - - Image depuis ton ordi : - { - setImageUrl(url); - convertUrlToFile(url).then(setImageFile); + + + + > + + + + } + label={ + postMeta.modified + ? `Modifié le ${new Intl.DateTimeFormat("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(postMeta.modified))}` + : "Modification en cours" + } + sx={{ + bgcolor: "rgba(255,255,255,0.78)", + color: "#0b1a3d", + fontWeight: 600, + }} + /> + + - - Ou choisir une image déjà envoyée : - - { - setImageUrl(url); - const file = await convertUrlToFile(url); - setImageFile(file); - }} - /> + {loading ? ( + + Chargement de l’article… + + ) : ( + + setTitle(event.target.value)} + required + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.94)", + }, + }} + /> - {imageUrl && ( - - Aperçu : - + + + + + + + Remplacer le visuel + + + Importez une nouvelle image optimisée pour vos + contenus. + + + { + setImageUrl(url); + convertUrlToFile(url).then(setImageFile); + }} + /> + + + + + + + + Sélectionner depuis la médiathèque + + + Choisissez un visuel existant dans Cloudinary. + + { + setImageUrl(url); + const file = await convertUrlToFile(url); + setImageFile(file); + }} + /> + + + + + + + + + )} + + + + + + + + + + + + + + + Rappels éditoriaux + + + • Vérifiez la cohérence des titres et sous-titres.
+ • Ajoutez des liens internes vers vos formations Octopus.
+ • N’oubliez pas de relire la mise en forme dans l’aperçu + WordPress. +
+
+
+
+ + + - - )} - - - - - + > + + Aperçu visuel + + {imageUrl ? ( + + ) : ( + + Aucun visuel sélectionné pour le moment + + )} + + Ce visuel illustrera l’article dans la liste des publications + et sur les réseaux sociaux. + + + +
+
+
+
); } diff --git a/frontend/src/components/GestionBureauEtudeACF.jsx b/frontend/src/components/GestionBureauEtudeACF.jsx index baa13cd..83e20ec 100644 --- a/frontend/src/components/GestionBureauEtudeACF.jsx +++ b/frontend/src/components/GestionBureauEtudeACF.jsx @@ -1,28 +1,128 @@ -import React, { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { getToken } from "../auth"; import { getPageById, updatePageACF } from "../wordpress"; import { Box, - Container, Typography, TextField, Button, - Paper, Grid, + Card, + CardContent, + Stack, + Divider, + 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 "../assets/styleCours.css"; const ETUDE_PAGE_ID = 272; +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.1, + py: 0.55, + fontSize: "0.78rem", + minHeight: 30, + }, + medium: { + px: 2.8, + py: 0.8, + fontSize: "0.86rem", + minHeight: 36, + }, + large: { + px: 3.4, + py: 1, + fontSize: "0.94rem", + minHeight: 42, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.28)", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.62)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.72)", + background: + "linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))", + boxShadow: "none", + border: "1px solid rgba(49,83,151,0.18)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.16)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.24)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.24)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + }, + outline: { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.95)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + }, + danger: { + background: "linear-gradient(135deg, #f05b6b, #ff8a80)", + color: "#3a0a0f", + border: "1px solid rgba(240,91,107,0.35)", + boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)", + "&:hover": { + background: "linear-gradient(135deg, #e34659, #ff7575)", + boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); + function EditPageEtude() { const navigate = useNavigate(); const [acfFields, setAcfFields] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(""); + const [submitting, setSubmitting] = useState(false); // Vérifie l'authentification useEffect(() => { @@ -50,15 +150,22 @@ function EditPageEtude() { }, []); // Mise à jour ACF - const handleUpdateACF = async (e) => { - e.preventDefault(); + const handleUpdateACF = async (event) => { + event.preventDefault(); + if (submitting) { + return; + } try { + setSubmitting(true); await updatePageACF(ETUDE_PAGE_ID, acfFields); setSuccessMessage("✅ Modifications enregistrées !"); setError(null); - setTimeout(() => navigate("/admin/dashboard"), 2000); - } catch { + setTimeout(() => navigate("/admin/dashboard"), 1800); + } catch (err) { + console.error("⚠ Impossible de mettre à jour les champs ACF :", err); setError("⚠ Impossible de mettre à jour les champs ACF."); + } finally { + setSubmitting(false); } }; @@ -98,126 +205,466 @@ function EditPageEtude() { setAcfFields(prev => ({ ...prev, [field]: updated })); }; - if (loading) return Chargement...; + const stats = useMemo(() => { + const toArray = (value) => + Array.isArray(value) + ? value + : value + ? Object.values(value) + : []; + const competencies = toArray(acfFields.liste_des_competences); + const expertises = toArray(acfFields.expertises_specifiques); + return { + competencies: competencies.length, + expertises: expertises.length, + }; + }, [acfFields]); + + if (loading) { + return ( + + + + + + + + Chargement de la page Bureau d’étude… + + + + ); + } return ( - - - + + + + + + + - - Modifier la Page Formations - + + + Bureau d’étude Octopus + + + Gestion des contenus ACF + + + Mettez à jour l’introduction, la méthodologie et les expertises du + bureau d’étude pour garder des informations pertinentes et à jour. + + + - {error && {error}} - {successMessage && {successMessage}} + + + + + Compétences listées + + + {stats.competencies} + + + Ajoutez des compétences pour valoriser l’expertise Octopus. + + + + + + + Expertises spécifiques + + + {stats.expertises} + + + Développez vos expertises pour renforcer la crédibilité de l’équipe. + + + + -
- - {/* Introduction */} - - Introduction - handleFieldChange("introduction", val)} - /> - + {error ? ( + + + {error} + + + ) : null} + {successMessage ? ( + + + {successMessage} + + + ) : null} - {/* Méthodologie */} - - Méthodologie - handleFieldChange("methodologie", e.target.value)} - /> - + + + + + + + + Introduction + + + Présentez la vision globale du bureau d’étude et son rôle + au sein des formations Octopus. + + + handleFieldChange("introduction", value)} + style={{ height: 220 }} + /> + + - {/* Liste des compétences */} - - Liste des compétences - {getRepeater("liste_des_competences").map((item, i) => ( - + + + + + Méthodologie + + + Décrivez en quelques lignes la manière dont le bureau + d’étude accompagne les clients et partenaires. + handleRepeaterChange("liste_des_competences", i, "comp_titre", e.target.value)} - sx={{ mb:1 }} + value={acfFields.methodologie || ""} + onChange={(event) => + handleFieldChange("methodologie", event.target.value) + } + multiline + minRows={3} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.94)", + }, + }} /> - handleRepeaterChange("liste_des_competences", i, "comp_description", e.target.value)} - sx={{ mb:1 }} - /> - - - ))} - - + + + + - {/* Expertises spécifiques */} - - Expertises spécifiques - {getRepeater("expertises_specifiques").map((item, i) => ( - - handleRepeaterChange("expertises_specifiques", i, "expertise_nom", e.target.value)} - sx={{ mb:1 }} - /> - handleRepeaterChange("expertises_specifiques", i, "expertise_details", e.target.value)} - sx={{ mb:1 }} - /> - - - ))} - - + + + + + + Liste des compétences + + + Ajoutez les compétences clés qui démontrent l’expertise du + bureau d’étude. Chaque compétence peut contenir un titre et + une description détaillée. + + - {/* Save */} - - - - -
-
-
+ + {getRepeater("liste_des_competences").map((item, index) => ( + + + + handleRepeaterChange( + "liste_des_competences", + index, + "comp_titre", + event.target.value + ) + } + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.9)", + }, + }} + /> + + handleRepeaterChange( + "liste_des_competences", + index, + "comp_description", + event.target.value + ) + } + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.9)", + }, + }} + /> + + + + ))} + + + + + + + + + + + + + Expertises spécifiques + + + Décrivez les expertises métiers ou sectorielles qui + distinguent votre bureau d’étude. + + + + + {getRepeater("expertises_specifiques").map((item, index) => ( + + + + handleRepeaterChange( + "expertises_specifiques", + index, + "expertise_nom", + event.target.value + ) + } + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.9)", + }, + }} + /> + + handleRepeaterChange( + "expertises_specifiques", + index, + "expertise_details", + event.target.value + ) + } + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.9)", + }, + }} + /> + + + + ))} + + + + + + + + + +
+
); } -export default EditPageEtude; \ No newline at end of file +export default EditPageEtude; diff --git a/frontend/src/components/GestionPagesACF.jsx b/frontend/src/components/GestionPagesACF.jsx index 5be97ef..464ed3b 100644 --- a/frontend/src/components/GestionPagesACF.jsx +++ b/frontend/src/components/GestionPagesACF.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { getToken } from "../auth"; import { fetchPages } from "../wordpress"; @@ -6,24 +6,109 @@ import { Box, Typography, Button, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, + Grid, + Card, + TextField, + Stack, + CircularProgress, } from "@mui/material"; import { Edit, ArrowBack, Add } from "@mui/icons-material"; +import "../assets/styleCours.css"; const HOME_PAGE_ID = 13; // Remplace 13 par l'ID réel de la page d'accueil const ETUDE_PAGE_ID = 272; // Remplace 13 par l'ID réel de la page d'accueil +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.1, + py: 0.55, + fontSize: "0.78rem", + minHeight: 30, + }, + medium: { + px: 2.8, + py: 0.8, + fontSize: "0.86rem", + minHeight: 36, + }, + large: { + px: 3.4, + py: 1, + fontSize: "0.94rem", + minHeight: 42, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.28)", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.62)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.72)", + background: + "linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))", + boxShadow: "none", + border: "1px solid rgba(49,83,151,0.18)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.16)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.24)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.24)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + }, + outline: { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.95)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); + function GestionPagesACF() { const navigate = useNavigate(); const [pages, setPages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); // ✅ Vérifier l'authentification useEffect(() => { @@ -49,7 +134,6 @@ function GestionPagesACF() { return; } - // ✅ Exclure la page d'accueil const filteredPages = response.filter( (page) => page.id !== HOME_PAGE_ID && page.id !== ETUDE_PAGE_ID, ); @@ -65,88 +149,333 @@ function GestionPagesACF() { loadPages(); }, []); - if (loading) return Chargement...; - if (error) return {error}; + const filteredPages = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return pages; + } + return pages.filter((page) => + page.title?.rendered?.toLowerCase?.().includes(term), + ); + }, [pages, searchTerm]); - return ( - + if (loading) { + return ( - - - 📄 Gestion des Pages Services - - + + + + + + + Chargement des pages services… + + - - - - - - Titre - - + + + + + + + {error} + + + + + ); + } + + return ( + + + + + + + + + + + + Gestion des pages services + + + Contenus ACF disponibles + + + Accédez rapidement aux pages service Octopus équipées de champs + ACF pour les mettre à jour en quelques clics. + + + + + + + + + + - Actions - - - - - {pages.length > 0 ? ( - pages.map((page) => ( - - - {page.title.rendered} - - - - - - )) - ) : ( - - - Aucune page avec des champs ACF trouvée. - - - )} - -
-
+ + + + {page.title.rendered} + + + Cliquez sur "Modifier" pour ajuster les contenus + de cette page service depuis l’éditeur ACF dédié. + + + + + + ))} + + )} +
); } diff --git a/frontend/src/components/Pages/Contact.jsx b/frontend/src/components/Pages/Contact.jsx index 3352755..ef1397e 100644 --- a/frontend/src/components/Pages/Contact.jsx +++ b/frontend/src/components/Pages/Contact.jsx @@ -22,6 +22,12 @@ import Faq from "../Faq"; const Contact = () => { + const CONTACT_API_URL = + import.meta.env.VITE_CONTACT_API_URL || + (import.meta.env.DEV + ? "http://localhost:3001/api/contact" + : "https://octopusdesign.fr/api/contact"); + const [metaTitle, setMetaTitle] = useState("Contactez-nous"); const [metaDescription, setMetaDescription] = useState( "Contactez notre équipe pour plus d'informations." @@ -35,7 +41,11 @@ const Contact = () => { }); const [errors, setErrors] = useState({}); const [loading, setLoading] = useState(false); - const [successMessage, setSuccessMessage] = useState(false); + const [alertState, setAlertState] = useState({ + open: false, + severity: "success", + message: "", + }); const [openLightbox, setOpenLightbox] = useState(false); const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ @@ -96,7 +106,7 @@ const Contact = () => { try { const response = await fetch( - "https://votre-site.com/wp-json/custom/v1/contact", + CONTACT_API_URL, { method: "POST", headers: { @@ -107,7 +117,11 @@ const Contact = () => { ); if (response.ok) { - setSuccessMessage(true); + setAlertState({ + open: true, + severity: "success", + message: "Merci ! Votre message a bien été envoyé.", + }); setFormData({ name: "", email: "", @@ -120,7 +134,13 @@ const Contact = () => { throw new Error(errorData.message || "Une erreur est survenue."); } } catch (error) { - alert(error.message); + setAlertState({ + open: true, + severity: "error", + message: + error.message || + "Impossible d'envoyer votre message. Veuillez réessayer.", + }); } finally { setLoading(false); } @@ -135,6 +155,13 @@ const Contact = () => { setOpenLightbox(false); }; + const handleCloseAlert = (_, reason) => { + if (reason === "clickaway") { + return; + } + setAlertState((prev) => ({ ...prev, open: false })); + }; + return ( {/* SEO */} @@ -490,12 +517,17 @@ const Contact = () => { setSuccessMessage(false)} + onClose={handleCloseAlert} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > - setSuccessMessage(false)} severity="success"> - Message envoyé avec succès ! + + {alertState.message} diff --git a/frontend/src/components/Pages/CreatePost.jsx b/frontend/src/components/Pages/CreatePost.jsx index 73893d4..ede5a34 100644 --- a/frontend/src/components/Pages/CreatePost.jsx +++ b/frontend/src/components/Pages/CreatePost.jsx @@ -4,22 +4,113 @@ import { uploadImageFromUrl, createPost } from "../../wordpress"; import { getToken } from "../../auth"; import { Box, - Container, Typography, TextField, Button, - Paper, + Grid, + Card, + CardContent, + Stack, + Chip, + Divider, } from "@mui/material"; -import { ArrowBack, Publish } from "@mui/icons-material"; +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 "../../assets/styleCours.css"; + +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.1, + py: 0.55, + fontSize: "0.78rem", + minHeight: 30, + }, + medium: { + px: 2.8, + py: 0.8, + fontSize: "0.86rem", + minHeight: 36, + }, + large: { + px: 3.4, + py: 1, + fontSize: "0.94rem", + minHeight: 42, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.35)", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.75)", + background: + "linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))", + boxShadow: "none", + border: "1px solid rgba(49,83,151,0.18)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.16)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.28)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.24)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + }, + outline: { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.96)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); function CreatePost() { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [imageUrl, setImageUrl] = useState(null); + const [submitting, setSubmitting] = useState(false); const navigate = useNavigate(); // Vérification de l'authentification @@ -29,126 +120,352 @@ function CreatePost() { } }, [navigate]); - const handleSubmit = async (e) => { - e.preventDefault(); + const canPublish = + title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl); + const handleSubmit = async (event) => { + event.preventDefault(); + if (!canPublish || submitting) { + return; + } try { + setSubmitting(true); const imageId = await uploadImageFromUrl(imageUrl); await createPost(title, content, imageId); alert("✅ Article publié !"); navigate("/admin/gestion-articles"); -} catch (error) { - console.error("❌ Erreur création post :", error.response?.data || error.message); - alert("❌ Erreur lors de la création du post."); -} - + } catch (error) { + console.error( + "❌ Erreur création post :", + error?.response?.data || error?.message || error + ); + alert("❌ Erreur lors de la création du post."); + } finally { + setSubmitting(false); + } }; return ( - - + + + + + + - - - Créer un article - - -
- setTitle(e.target.value)} - required - /> - - - setContent(e.target.value)} - required - style={{height:"100%"}} - /> - - - {/* Upload via Cloudinary */} - - - Importer une image depuis ton ordi : - - setImageUrl(url)} /> - - - {/* Sélection galerie Cloudinary */} - - - Ou choisir une image déjà envoyée : - - setImageUrl(url)} /> - - - {/* Aperçu */} - {imageUrl && ( - - Aperçu de l’image : - Image sélectionnée - - )} - - - -
-
+ Publication Octopus + + + Rédiger un nouvel article + + + Structurez votre contenu, ajoutez une image mise en avant et + publiez directement sur le blog WordPress Octopus. + + + + + + + + + + + + + + + setTitle(event.target.value)} + required + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: "rgba(255,255,255,0.94)", + }, + }} + /> + + + + + + + + + Importer une image + + + Ajoutez un visuel optimisé pour les réseaux sociaux + (format 1600x900 recommandé). + + + setImageUrl(url)} + /> + + + + + + + + Sélectionner depuis la médiathèque + + + Retrouver un visuel déjà téléversé dans Cloudinary. + + setImageUrl(url)} + /> + + + + + + + + + + + + + + + + + + + + + + + Conseils de rédaction + + + • Introduisez le sujet dans les 250 premiers caractères pour + optimiser le SEO.
+ • Utilisez des sous-titres (<h2>) pour + clarifier la structure.
+ • Ajoutez des appels à l’action vers vos formations Octopus. +
+
+
+
+ + + + + Aperçu visuel + + {imageUrl ? ( + + ) : ( + + Aucun visuel sélectionné pour le moment + + )} + + Ce visuel sera utilisé comme image de couverture sur la liste + des articles et les réseaux sociaux. + + + +
+
+
+
); } diff --git a/frontend/src/components/Pages/Dashboard.jsx b/frontend/src/components/Pages/Dashboard.jsx index ef34ef3..654f096 100644 --- a/frontend/src/components/Pages/Dashboard.jsx +++ b/frontend/src/components/Pages/Dashboard.jsx @@ -7,18 +7,119 @@ import { Grid, Card, CardContent, - IconButton, Button, + Stack, + Chip, + Divider, } from "@mui/material"; -import LogoutIcon from "@mui/icons-material/Logout"; // Icône de déconnexion +import LogoutIcon from "@mui/icons-material/Logout"; import ArticleIcon from "@mui/icons-material/Article"; -import DashboardIcon from "@mui/icons-material/Dashboard"; -import HomeIcon from "@mui/icons-material/Home"; // Icône pour la gestion page d'accueil +import HomeIcon from "@mui/icons-material/Home"; import BuildIcon from "@mui/icons-material/Build"; +import SchoolIcon from "@mui/icons-material/School"; +import InsightsIcon from "@mui/icons-material/Insights"; +import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest"; +import AutoStoriesIcon from "@mui/icons-material/AutoStories"; +import PublicIcon from "@mui/icons-material/Public"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; + +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.1, + py: 0.55, + fontSize: "0.78rem", + minHeight: 30, + }, + medium: { + px: 2.8, + py: 0.8, + fontSize: "0.86rem", + minHeight: 36, + }, + large: { + px: 3.4, + py: 1, + fontSize: "0.94rem", + minHeight: 42, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.35)", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.72)", + background: + "linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))", + boxShadow: "none", + border: "1px solid rgba(49,83,151,0.18)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.16)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.28)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.24)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + }, + outline: { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.32)", + "&:hover": { + background: "rgba(255,255,255,0.96)", + borderColor: "rgba(49,83,151,0.48)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + }, + subtle: { + background: "rgba(49,83,151,0.08)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.12)", + "&:hover": { + background: "rgba(49,83,151,0.14)", + borderColor: "rgba(49,83,151,0.24)", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); function Dashboard() { const navigate = useNavigate(); - const token = getToken(); +const token = getToken(); useEffect(() => { if (!token) { @@ -31,240 +132,390 @@ function Dashboard() { navigate("/admin/login"); // Redirige vers la page de connexion }; + const NAV_ITEMS = [ + { + title: "Page d’accueil", + description: "Mettez à jour les blocs héros, témoignages et sections clés.", + navigateTo: "/admin/Gestion-Page-Accueil", + icon: , + accent: "rgba(123,192,255,0.35)", + }, + { + title: "Tools studio", + description: "Centralisez vos outils internes et automatisez les tâches.", + navigateTo: "/admin/tools", + icon: , + accent: "rgba(255,214,150,0.32)", + }, + { + title: "Articles", + description: "Rédigez, programmez et optimisez les publications du blog.", + navigateTo: "/admin/gestion-articles", + icon: , + accent: "rgba(255,172,236,0.28)", + }, + { + title: "Pages services", + description: "Actualisez les contenus ACF pour chaque offre Octopus.", + navigateTo: "/admin/pages-acf", + icon: , + accent: "rgba(158,241,209,0.32)", + }, + { + title: "Bureau d’étude", + description: "Administrez les informations du pôle bureau d’étude.", + navigateTo: "/admin/bureau-etude-acf", + icon: , + accent: "rgba(255,199,186,0.34)", + }, + { + title: "Cours & formations", + description: "Gérez le catalogue Moodle et vos ressources pédagogiques.", + navigateTo: "/admin/liste-cours", + icon: , + accent: "rgba(178,219,255,0.34)", + }, + ]; + + const QUICK_ACTIONS = [ + { + label: "Nouvel article", + hint: "Créez un contenu en un clic", + onClick: () => navigate("/admin/gestion-articles"), + }, + { + label: "Synchroniser Moodle", + hint: "Actualisez vos cours et stats", + onClick: () => navigate("/admin/liste-cours"), + }, + { + label: "Voir le site", + hint: "Ouvrir octopusdesign.fr", + onClick: () => window.open("https://www.octopusdesign.fr", "_blank"), + }, + ]; + + const KPI_CARDS = [ + { + label: "Articles publiés", + value: "128", + delta: "+4 ce mois-ci", + }, + { + label: "Cours actifs", + value: "36", + delta: "8 en mise à jour", + }, + { + label: "Pages services", + value: "12", + delta: "2 révisions en cours", + }, + ]; + return ( - - {/* Bouton de déconnexion placé en haut à gauche */} - + + + + + + + Priorités du jour + + + Suivez la progression des mises en ligne, synchronisez Moodle + et gardez un œil sur vos contenus essentiels. + + + } + label="Contenus pédagogiques à jour" + sx={{ + bgcolor: "rgba(123,192,255,0.16)", + color: "#0b1a3d", + fontWeight: 600, + }} + /> + } + label="Octopusdesign.fr en production" + sx={{ + bgcolor: "rgba(255,214,150,0.18)", + color: "#0b1a3d", + fontWeight: 600, + }} + /> + + + + + + Actions rapides + + + {QUICK_ACTIONS.map((action) => ( + + ))} + + + + + + + {KPI_CARDS.map((card) => ( + + + + {card.label.toUpperCase()} + + + {card.value} + + + {card.delta} + + + + ))} + + + + Espaces de gestion - {/* Cartes du Dashboard */} - - {/* ✅ Gestion Page d'Accueil - Première carte */} - - navigate("/admin/Gestion-Page-Accueil")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Gestion Page d'Accueil - - - - - - navigate("/admin/tools")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Tools - - - - - - {/* ✅ Gérer les Articles - Deuxième carte */} - - navigate("/admin/gestion-articles")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Gérer les Articles - - - - - - {/* ✅ Gérer les Pages ACF - Nouvelle carte */} - - navigate("/admin/pages-acf")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Gérer les Pages Services - - - - - - {/* ✅ Gérer les Pages ACF - Nouvelle carte */} - - navigate("/admin/bureau-etude-acf")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Gérer la page bureau d'étude - - - - - {/* ✅ Gérer les Cours - Nouvelle carte */} - - navigate("/admin/liste-cours")} - sx={{ - cursor: "pointer", - textAlign: "center", - padding: 2, - transition: "0.3s", - "&:hover": { - backgroundColor: "#0e467f", - color: "#ffffff", - transform: "scale(1.05)", - boxShadow: 8, - }, - "&:hover svg": { - fill: "#ffffff", - }, - }} - > - - - - - - Cours - - - - - {/* ✅ Gérer les Cours - Nouvelle carte */} + + {NAV_ITEMS.map((item) => ( + + navigate(item.navigateTo)} + className="glass-subcard" + sx={{ + cursor: "pointer", + borderRadius: 3, + overflow: "hidden", + position: "relative", + transition: + "transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease", + "&::before": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "inherit", + background: `radial-gradient(360px at 18% 24%, ${item.accent}, transparent 72%)`, + opacity: 0.9, + }, + "&:hover": { + transform: "translateY(-8px)", + borderColor: "rgba(49,83,151,0.35)", + boxShadow: "0 38px 40px -30px rgba(12, 29, 74, 0.6)", + }, + }} + > + + + {item.icon} + + + {item.title} + + + {item.description} + + + + + + ))} diff --git a/frontend/src/components/Pages/GestionArticles.jsx b/frontend/src/components/Pages/GestionArticles.jsx index b1dce05..29232e8 100644 --- a/frontend/src/components/Pages/GestionArticles.jsx +++ b/frontend/src/components/Pages/GestionArticles.jsx @@ -1,18 +1,118 @@ // ✅ Importations nécessaires -import React, { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { - Box, Typography, Button, Table, TableBody, TableCell, - TableContainer, TableHead, TableRow, Paper, TextField, Avatar + Box, + Typography, + Button, + Grid, + Card, + CardContent, + CardMedia, + TextField, + Stack, + Chip, + CircularProgress, + Tooltip, } from "@mui/material"; import { Add, ArrowBack, Edit, Delete } from "@mui/icons-material"; import api from "../../api"; import { getToken } from "../../auth"; import { deletePost } from "../../wordpress"; +import "../../assets/styleCours.css"; + +const BUTTON_BASE_SX = Object.freeze({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + "&:hover": { + transform: "translateY(-1px)", + }, + "&:active": { + transform: "translateY(0)", + }, +}); + +const BUTTON_SIZE_SX = Object.freeze({ + small: { + px: 2.1, + py: 0.55, + fontSize: "0.78rem", + minHeight: 30, + }, + medium: { + px: 2.8, + py: 0.8, + fontSize: "0.86rem", + minHeight: 36, + }, + large: { + px: 3.4, + py: 1, + fontSize: "0.94rem", + minHeight: 42, + }, +}); + +const BUTTON_VARIANT_SX = Object.freeze({ + primary: { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.35)", + boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)", + }, + }, + outline: { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.96)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)", + }, + }, + ghost: { + background: "rgba(255,255,255,0.16)", + color: "rgba(255,255,255,0.92)", + border: "1px solid rgba(255,255,255,0.28)", + backdropFilter: "blur(8px)", + "&:hover": { + background: "rgba(255,255,255,0.24)", + boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)", + }, + }, + danger: { + background: "linear-gradient(135deg, #f05b6b, #ff8a80)", + color: "#3a0a0f", + border: "1px solid rgba(240,91,107,0.35)", + boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)", + "&:hover": { + background: "linear-gradient(135deg, #e34659, #ff7575)", + boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)", + }, + }, +}); + +const composeButtonSx = (variant, size = "medium") => ({ + ...BUTTON_BASE_SX, + ...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium), + ...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary), +}); const GestionArticles = () => { const [posts, setPosts] = useState([]); const [searchTerm, setSearchTerm] = useState(""); + const [loading, setLoading] = useState(true); const navigate = useNavigate(); // ✅ Vérifier si l'utilisateur est connecté @@ -26,7 +126,10 @@ const GestionArticles = () => { useEffect(() => { const fetchPosts = async () => { try { - const response = await api.get("wp/v2/posts?_fields=id,title,excerpt,featured_media"); + setLoading(true); + const response = await api.get( + "wp/v2/posts?_fields=id,title,excerpt,featured_media,date,status" + ); const postsData = response.data; const postsWithImages = await Promise.all( @@ -47,6 +150,8 @@ const GestionArticles = () => { setPosts(postsWithImages); } catch (error) { console.error("❌ Erreur chargement des articles :", error); + } finally { + setLoading(false); } }; @@ -54,112 +159,408 @@ const GestionArticles = () => { }, []); // ✅ Supprimer un article et son image associée - const handleDeletePost = async (postId, imageId) => { - if (window.confirm("Es-tu sûr de vouloir supprimer cet article ?")) { - try { - await deletePost(postId, imageId); // ✅ Appel de la fonction deletePost - setPosts(posts.filter((post) => post.id !== postId)); // ✅ Met à jour la liste après suppression - alert("✅ Article supprimé avec succès !"); - } catch (error) { - console.error("❌ Erreur suppression article :", error); - alert("⚠ Erreur : impossible de supprimer l'article."); - } - } -}; + const stripHtml = (value = "") => + value.replace(/(<([^>]+)>)/gi, "").replace(/ /gi, " ").trim(); - // ✅ Filtrage des articles - const filteredPosts = posts.filter((post) => - post.title.rendered.toLowerCase().includes(searchTerm.toLowerCase()) || - post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/ /g, " ").toLowerCase().includes(searchTerm.toLowerCase()) - ); + const previewExcerpt = (value = "", limit = 160) => { + const clean = stripHtml(value); + return clean.length > limit ? `${clean.slice(0, limit)}…` : clean; + }; + + const formatDate = (value) => { + if (!value) { + return "Date inconnue"; + } + try { + return new Intl.DateTimeFormat("fr-FR", { + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date(value)); + } catch { + return "Date inconnue"; + } + }; + + const handleDeletePost = async (postId, imageId) => { + const confirmed = window.confirm( + "Supprimer cet article et son visuel associé ?" + ); + if (!confirmed) { + return; + } + try { + await deletePost(postId, imageId); + setPosts((prev) => prev.filter((post) => post.id !== postId)); + alert("✅ Article supprimé avec succès !"); + } catch (error) { + console.error("❌ Erreur suppression article :", error); + alert("⚠ Erreur : impossible de supprimer l'article."); + } + }; + + const handleCreate = () => navigate("/admin/create-post"); + const handleBack = () => navigate("/admin/dashboard"); + const handleEdit = (postId) => navigate(`/admin/edit-post/${postId}`); + + const filteredPosts = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return posts; + } + return posts.filter((post) => { + const title = post.title?.rendered?.toLowerCase?.() || ""; + const excerpt = stripHtml(post.excerpt?.rendered || "").toLowerCase(); + return title.includes(term) || excerpt.includes(term); + }); + }, [posts, searchTerm]); + + const stats = useMemo(() => { + const total = posts.length; + const withImage = posts.reduce( + (acc, post) => (post.image ? acc + 1 : acc), + 0 + ); + const latest = posts.reduce((current, candidate) => { + if (!candidate?.date) { + return current; + } + if (!current) { + return candidate; + } + return new Date(candidate.date) > new Date(current.date) + ? candidate + : current; + }, null); + return { + total, + withImage, + withoutImage: total - withImage, + latestLabel: latest?.title?.rendered || "Aucun article", + latestDate: latest?.date ? formatDate(latest.date) : "—", + }; + }, [posts]); return ( - - {/* ✅ En-tête avec retour et création */} - - - - Gestion des Articles - - + + + + + + + + + + + Contenu éditorial + + + Gestion des articles + + + Consultez les publications WordPress, ajustez vos contenus et + maintenez un flux éditorial cohérent pour Octopus. + + + + + + + + + + Total articles + + + {stats.total} + + + {filteredPosts.length} résultat(s) après filtre + + + + + + + Articles illustrés + + + {stats.withImage} + + + {stats.withoutImage} sans visuel associé + + + + + + + Dernière publication + + + {stats.latestLabel} + + + {stats.latestDate} + + + + + + + + + + Rechercher un article + + + Filtrez par titre ou résumé pour retrouver un contenu en quelques + secondes. + + + setSearchTerm(event.target.value)} + sx={{ + maxWidth: 380, + "& .MuiOutlinedInput-root": { + borderRadius: 999, + backgroundColor: "rgba(255,255,255,0.92)", + }, + }} + /> + + + + {loading ? ( + + + + ) : filteredPosts.length === 0 ? ( + + + Aucun article trouvé + + + Ajustez votre recherche ou créez un nouvel article pour alimenter le blog. + + + + ) : ( + + {filteredPosts.map((post) => ( + + + + + + + + + + + + {post.title.rendered} + + + {previewExcerpt(post.excerpt?.rendered)} + + + + + + + + + ))} + + )} - - {/* ✅ Recherche */} - setSearchTerm(e.target.value)} - sx={{ mb: 3 }} - /> - - {/* ✅ Tableau des articles */} - - - - - Image - Titre - Résumé - Actions - - - - {filteredPosts.length > 0 ? ( - filteredPosts.map((post) => ( - - {/* ✅ Image en vedette */} - - - - {/* ✅ Titre de l'article */} - {post.title.rendered} - {/* ✅ Résumé avec 100 caractères max */} - - {post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/ /g, " ").substring(0, 100)}... - - {/* ✅ Boutons Modifier et Supprimer */} - - - - - - )) - ) : ( - - - Aucun article trouvé. - - - )} - -
-
); }; -export default GestionArticles; \ No newline at end of file +export default GestionArticles; diff --git a/frontend/src/components/Pages/Login.jsx b/frontend/src/components/Pages/Login.jsx index 4ff4e00..aaf4952 100644 --- a/frontend/src/components/Pages/Login.jsx +++ b/frontend/src/components/Pages/Login.jsx @@ -4,29 +4,35 @@ import { useNavigate } from "react-router-dom"; // Import de useNavigate pour la import { Box, Button, - Card, - CardContent, + Grid, TextField, Typography, - Container, - Avatar, + Stack, + CircularProgress, + Chip, } from "@mui/material"; import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import "../../assets/styleCours.css"; function TestLogin() { const [username, setUsername] = useState(""); const [appPassword, setAppPassword] = useState(""); const [token, setToken] = useState(getToken()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); const navigate = useNavigate(); // Hook pour la navigation const handleLogin = async (e) => { e.preventDefault(); + setError(""); + setLoading(true); const newToken = await loginWithAppPassword(username, appPassword); if (newToken) { setToken(newToken); } else { - alert("Échec de la connexion !"); + setError("Échec de la connexion. Vérifiez vos identifiants."); } + setLoading(false); }; const handleLogout = () => { @@ -34,92 +40,232 @@ function TestLogin() { setToken(null); }; + const composeButtonSx = (variant = "primary") => ({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + px: 3, + py: 0.85, + minHeight: 42, + ...(variant === "primary" + ? { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.28)", + boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)", + }, + } + : { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.95)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)", + }, + }), + }); + return ( - - - - - - - - {token ? "Bienvenue !" : "Connexion"} - + + + - {token ? ( - <> - + + + ) : ( + - Accéder au tableau de bord - - - - ) : ( -
- setUsername(e.target.value)} - required - /> - setAppPassword(e.target.value)} - required - /> - - - )} -
-
-
+ + setUsername(e.target.value)} + required + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 999, + backgroundColor: "rgba(255,255,255,0.95)", + }, + }} + /> + setAppPassword(e.target.value)} + required + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 999, + backgroundColor: "rgba(255,255,255,0.95)", + }, + }} + /> + {error ? ( + + {error} + + ) : null} + + +
+ )} + +
+ +
); } -export default TestLogin; \ No newline at end of file +export default TestLogin; diff --git a/frontend/src/components/Pages/Tools.jsx b/frontend/src/components/Pages/Tools.jsx index cd42dcc..3b0b35c 100644 --- a/frontend/src/components/Pages/Tools.jsx +++ b/frontend/src/components/Pages/Tools.jsx @@ -1,8 +1,17 @@ import { useEffect } from "react"; -import { Box, Typography, Grid, Card, CardContent, Button } from "@mui/material"; +import { + Box, + Typography, + Grid, + Card, + Button, + Stack, + Chip, +} from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useNavigate } from "react-router-dom"; import { getToken } from "../../auth"; +import "../../assets/styleCours.css"; const toolsApps = [ { @@ -46,40 +55,172 @@ function Tools() { navigate(app.url); }; - return ( - - - - - Mes applications - - + const composeButtonSx = (variant = "primary") => ({ + borderRadius: 999, + textTransform: "none", + fontWeight: 600, + letterSpacing: 0.3, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 0.75, + transition: + "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease", + px: 3, + py: 0.85, + minHeight: 42, + ...(variant === "primary" + ? { + background: "linear-gradient(135deg, #315397, #7bc0ff)", + color: "#ffffff", + border: "1px solid rgba(255,255,255,0.28)", + boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)", + "&:hover": { + background: "linear-gradient(135deg, #26467d, #6fb6ff)", + boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)", + }, + } + : { + background: "rgba(255,255,255,0.84)", + color: "#315397", + border: "1px solid rgba(49,83,151,0.26)", + "&:hover": { + background: "rgba(255,255,255,0.95)", + borderColor: "rgba(49,83,151,0.46)", + boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)", + }, + }), + }); - - {toolsApps.map((app) => ( - - - - {app.name} - - {app.description} - - - - - - - - ))} - + return ( + + + + + + + + + + + Hub applicatif Octopus + + + Mes outils métiers + + + Centralisez vos applications internes : réseau, bilans, générateur + de photos et futurs modules d’automatisation Octopus. + + + + + + + + + + + {toolsApps.map((app) => ( + + + + + {app.external ? "Application externe" : "Module interne"} + + + {app.name} + + + {app.description} + + + + + + ))} + + ); } diff --git a/server/cloudinary-backend/.DS_Store b/server/cloudinary-backend/.DS_Store new file mode 100644 index 0000000..f2ea631 Binary files /dev/null and b/server/cloudinary-backend/.DS_Store differ diff --git a/server/cloudinary-backend/.env.example b/server/cloudinary-backend/.env.example new file mode 100644 index 0000000..5679128 --- /dev/null +++ b/server/cloudinary-backend/.env.example @@ -0,0 +1,28 @@ +# +# Backend configuration for contact form emails. +# Copy this file to `.env` and fill in your SMTP details. +# + +# SMTP server host (e.g. smtp.gmail.com, smtp.mailgun.org, etc.) +SMTP_HOST=mail.sebvtl.com + +# SMTP port (465 for SSL, 587 for TLS) +SMTP_PORT=465 + +# Use "true" if your provider requires SSL (port 465) +# Otherwise leave to "false" for TLS (port 587) +SMTP_SECURE=true + +# SMTP credentials +SMTP_USER=contact@sebvtl.com +SMTP_PASS=Lightwave9.0** + +# Optional: override the recipient (defaults to sebastien@octopusdesign.fr) +CONTACT_RECIPIENT=sebastien@octopusdesign.fr + +# Optional: override the "from" address visible in the email client +CONTACT_FROM=no-reply@octopusdesign.fr + +# Optional: add extra allowed origins for CORS (comma separated) +# Example: CORS_ALLOWED_ORIGINS=https://preprod.octopusdesign.fr,https://admin.octopusdesign.fr +CORS_ALLOWED_ORIGINS= diff --git a/server/cloudinary-backend/contactRoute.js b/server/cloudinary-backend/contactRoute.js new file mode 100644 index 0000000..df5515d --- /dev/null +++ b/server/cloudinary-backend/contactRoute.js @@ -0,0 +1,123 @@ +import express from "express"; +import nodemailer from "nodemailer"; + +const router = express.Router(); + +const buildTransporterConfig = () => { + const host = process.env.SMTP_HOST; + const port = Number(process.env.SMTP_PORT || 587); + const secure = + typeof process.env.SMTP_SECURE === "string" + ? process.env.SMTP_SECURE === "true" + : port === 465; + + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + const baseConfig = { + host, + port, + secure, + }; + + if (user && pass) { + baseConfig.auth = { user, pass }; + } + + return baseConfig; +}; + +router.post("/contact", async (req, res) => { + const { name, email, subject, message, consent } = req.body || {}; + + console.info("📨 Requête contact reçue", { + name, + email, + subject, + consent, + }); + + if (!name || !email || !subject || !message) { + return res.status(400).json({ + message: + "Merci de compléter tous les champs requis avant d'envoyer votre message.", + }); + } + + const recipient = + process.env.CONTACT_RECIPIENT || "sebastien@octopusdesign.fr"; + const fromAddress = + process.env.CONTACT_FROM || + process.env.SMTP_FROM || + process.env.SMTP_USER || + "no-reply@octopusdesign.fr"; + + try { + const transporter = nodemailer.createTransport(buildTransporterConfig()); + + const mailSubject = subject.trim() + ? `[Site Octopus Design] ${subject.trim()}` + : "Nouveau message depuis le site Octopus Design"; + + const consentLabel = consent ? "✅ Consentement donné" : "❌ Consentement non fourni"; + + const textBody = ` +Nom : ${name} +E-mail : ${email} +Consentement : ${consentLabel} + +Message : +${message} +`; + + const htmlBody = ` +

Nom : ${name}

+

E-mail : ${email}

+

Consentement : ${consentLabel}

+

Message :

+

${message.replace(/\n/g, "
")}

+ `; + + // Vérifie la connexion SMTP avant d'essayer d'envoyer + await transporter.verify(); + + const mailOptions = { + from: `"Octopus Design" <${fromAddress}>`, + to: recipient, + replyTo: email, + subject: mailSubject, + text: textBody, + html: htmlBody, + }; + + await transporter.sendMail(mailOptions); + + console.info( + "✅ Email de contact envoyé avec succès", + JSON.stringify( + { + to: recipient, + replyTo: email, + subject: mailSubject, + }, + null, + 2 + ) + ); + + return res.status(200).json({ + message: "Votre message a bien été envoyé. Merci pour votre confiance !", + }); + } catch (error) { + console.error( + "❌ Erreur lors de l'envoi du mail de contact :", + error?.stack || error?.message || error + ); + return res.status(500).json({ + message: + "Impossible d'envoyer votre message pour le moment. Merci de réessayer plus tard.", + }); + } +}); + +export default router; diff --git a/server/cloudinary-backend/main.js b/server/cloudinary-backend/main.js index 4c5bf54..651ad36 100644 --- a/server/cloudinary-backend/main.js +++ b/server/cloudinary-backend/main.js @@ -1,17 +1,37 @@ import express from "express"; import dotenv from "dotenv"; -import cloudinaryRoute from "./cloudinaryRoute.js"; import cors from "cors"; +import cloudinaryRoute from "./cloudinaryRoute.js"; +import contactRoute from "./contactRoute.js"; dotenv.config(); const app = express(); -app.use(cors({ - // origin: "https://octopusdesign.fr" - origin: ["https://octopusdesign.fr", "http://localhost:3000"] -})); +const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +const defaultOrigins = [ + "https://octopusdesign.fr", + "https://www.octopusdesign.fr", + "https://preprod.octopusdesign.fr", + "http://localhost:3000", + "http://localhost:5173", +]; + +const origins = [...new Set([...defaultOrigins, ...allowedOrigins])]; + +app.use( + cors({ + origin: origins, + }) +); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); app.use("/api", cloudinaryRoute); +app.use("/api", contactRoute); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { diff --git a/server/cloudinary-backend/package.json b/server/cloudinary-backend/package.json index c1ac5c1..be07cfb 100644 --- a/server/cloudinary-backend/package.json +++ b/server/cloudinary-backend/package.json @@ -10,6 +10,7 @@ "axios": "^1.6.7", "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2" + "express": "^4.18.2", + "nodemailer": "^6.9.11" } }