1 Commits

Author SHA1 Message Date
sebvtl728 cf77437c89 MAJ Article 2025-01-31 17:28:21 +01:00
69 changed files with 1454 additions and 22409 deletions
Vendored
BIN
View File
Binary file not shown.
-28
View File
@@ -1,30 +1,2 @@
# 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 lAPI de contact.
Suivez ces étapes pour activer lenvoi de-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, lAPI 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 lapplication React :
```bash
cd frontend
npm run dev
```
En production, assurez-vous que lURL 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 denvironnement SMTP.
-3
View File
@@ -1,3 +0,0 @@
# URL of the contact API (backend server).
# In development, point to the local Express server.
VITE_CONTACT_API_URL=http://localhost:3001/api/contact
-2
View File
@@ -11,8 +11,6 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
# Editor directories and files
.vscode/*
-17
View File
@@ -6,20 +6,3 @@ 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`.
+13 -88
View File
@@ -1,101 +1,26 @@
<!DOCTYPE html>
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title data-rh="true">Centre de formations - metier du numérique</title>
<title>bureau d’études Maîtrise doeuvre</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<!-- ✅ Ajout Open Graph statique -->
<meta
data-rh="true"
property="og:title"
content="Centre de formations web IA"
/>
<meta
data-rh="true"
property="og:description"
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée"
/>
<meta
data-rh="true"
property="og:image"
content="https://res.cloudinary.com/dh5qgexjo/image/upload/v1753856912/centre-formation-metier-numerique_iwxjzv.avif"
/>
<meta data-rh="true" property="og:type" content="website" />
<meta data-rh="true" property="og:url" content="https://ton-site.com" />
<meta property="og:title" content="Titre par défaut - Bureau d’études">
<meta property="og:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
<meta property="og:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
<meta property="og:type" content="website">
<meta property="og:url" content="https://ton-site.com">
<!-- ✅ Twitter Cards -->
<meta data-rh="true" name="twitter:card" content="summary_large_image" />
<meta
data-rh="true"
name="twitter:title"
content="Centre de formations web IA"
/>
<meta
data-rh="true"
name="twitter:description"
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée."
/>
<meta
data-rh="true"
name="twitter:image"
content="https://res.cloudinary.com/dh5qgexjo/image/upload/v1753856912/centre-formation-metier-numerique_iwxjzv.avif"
/>
<!-- <link rel="preload" href="/src/assets/vendor-BFTbvl5C.js" as="script"> -->
<link rel="preload" href="/assets/index-CfManBR6.css" as="style" />
<link rel="stylesheet" href="/assets/index-CfManBR6.css" />
<script src="https://unpkg.com/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
<script src="https://media-library.cloudinary.com/global/all.js"></script>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Titre par défaut - Bureau d’études">
<meta name="twitter:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
<meta name="twitter:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
<link rel="preload" href="/src/assets/vendor-BFTbvl5C.js" as="script">
</head>
<body>
<div id="root"></div>
<noscript>
<style>
.no-js-wrapper {
padding: 1.5rem;
font-family: "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
background: #f5f7fb;
color: #0b1f44;
}
.no-js-wrapper a {
color: #0b5ed7;
text-decoration: none;
}
.no-js-wrapper a:hover,
.no-js-wrapper a:focus {
text-decoration: underline;
}
.no-js-nav {
margin: 1rem 0;
padding-left: 1.2rem;
}
</style>
<section class="no-js-wrapper">
<h2>Navigation sans JavaScript</h2>
<p>
Le site s&apos;appuie sur React et nécessite JavaScript pour l&apos;expérience complète.
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
utilisez ces liens directs&nbsp;:
</p>
<ul class="no-js-nav">
<li><a href="/">Accueil</a></li>
<li><a href="/services/prestation-maitrise-oeuvre/">Création de site web</a></li>
<li><a href="/services/formations-web/">Formations web</a></li>
<li><a href="/services/electricite/">Graphisme</a></li>
<li><a href="/services/service-securite-incendie/">Podcast</a></li>
<li><a href="/services/expertises-tce/">Formations professionnelles</a></li>
<li><a href="/nosFormations/">Nos formations</a></li>
<li><a href="/posts/">Actualités</a></li>
<li><a href="/contact/">Contact</a></li>
<li><a href="/search/">Recherche</a></li>
<li><a href="/sitemap.xml">Plan du site complet</a></li>
</ul>
<p>
Retrouvez aussi nos informations légales et politiques via le plan du site.
</p>
</section>
</noscript>
<script type="module" src="/src/main.jsx" defer></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+288 -1248
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -5,12 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/update-noscript.mjs",
"build": "vite build",
"postbuild": "node scripts/prerender.mjs",
"lint": "eslint .",
"preview": "vite preview",
"prerender": "node scripts/prerender.mjs"
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -18,17 +15,12 @@
"@mui/icons-material": "^6.3.0",
"@mui/material": "^6.3.0",
"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",
"react-helmet-async": "^2.0.5",
"react-quill": "^2.0.0",
"react-router-dom": "^7.1.1",
"react-simple-lightbox": "^1.0.26",
"react-toastify": "^11.0.3",
"simple-react-lightbox": "^3.6.8",
"svgo": "^3.3.2",
"swiper": "^10.3.1"
@@ -45,7 +37,6 @@
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.4.49",
"puppeteer": "^24.28.0",
"rollup-plugin-visualizer": "^5.14.0",
"vite": "^6.0.5",
"vite-plugin-compression": "^0.5.1"
+4 -1
View File
@@ -1,2 +1,5 @@
User-agent: *
Disallow: /
Disallow: /static/
Allow: /
Sitemap: https://octopusdesign.fr/sitemap.xml
-149
View File
@@ -1,149 +0,0 @@
import { createServer } from "node:http";
import { readFile, stat, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import puppeteer from "puppeteer";
import { PUBLIC_ROUTES, NOT_FOUND_ROUTE } from "./routes.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const distDir = path.resolve(__dirname, "../dist");
const routes = PUBLIC_ROUTES.filter((route) => route.prerender).map(
(route) => route.path
);
const mimeTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".avif": "image/avif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".txt": "text/plain",
".xml": "application/xml",
".map": "application/json",
".gz": "application/gzip",
".br": "application/brotli",
};
function serveStaticFile(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] ?? "application/octet-stream";
res.statusCode = 200;
res.setHeader("Content-Type", contentType);
return readFile(filePath).then((data) => res.end(data));
}
function createStaticServer() {
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", "http://localhost");
let pathname = decodeURIComponent(requestUrl.pathname);
const hasExtension = path.extname(pathname) !== "";
if (pathname.endsWith("/")) {
pathname = pathname.slice(0, -1);
}
let filePath = path.join(distDir, pathname);
try {
const fileStat = await stat(filePath);
if (fileStat.isDirectory()) {
filePath = path.join(filePath, "index.html");
}
await serveStaticFile(res, filePath);
return;
} catch (error) {
if (hasExtension) {
res.statusCode = 404;
res.end("Not found");
return;
}
filePath = path.join(distDir, "index.html");
await serveStaticFile(res, filePath);
}
} catch (error) {
res.statusCode = 500;
res.end(`Server error: ${error instanceof Error ? error.message : error}`);
}
});
return new Promise((resolve) => {
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
resolve({ server, port: address.port });
} else {
resolve({ server, port: 4173 });
}
});
});
}
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function renderRoute(browser, baseUrl, route, outputFile) {
const page = await browser.newPage();
const targetUrl = `${baseUrl}${route}`;
try {
await page.goto(targetUrl, {
waitUntil: ["networkidle0", "domcontentloaded"],
timeout: 60000,
});
} catch (error) {
console.warn(
`[prerender] Navigation to ${targetUrl} failed: ${
error instanceof Error ? error.message : error
}`
);
}
try {
// Donne un léger délai pour laisser React terminer le rendu asynchrone
await delay(1500);
const html = await page.content();
const outputPath =
outputFile ??
(route === "/"
? path.join(distDir, "index.html")
: path.join(distDir, route.replace(/^\//, ""), "index.html"));
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, html, "utf8");
console.log(
`✔ prerendered ${route} -> ${path.relative(distDir, outputPath) || "index.html"}`
);
} finally {
await page.close();
}
}
async function main() {
const { server, port } = await createStaticServer();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await puppeteer.launch({ headless: true });
try {
for (const route of routes) {
await renderRoute(browser, baseUrl, route);
}
await renderRoute(browser, baseUrl, NOT_FOUND_ROUTE, path.join(distDir, "404.html"));
} finally {
await browser.close();
server.close();
}
}
main().catch((error) => {
console.error("[prerender] Failed to prerender routes:", error);
process.exit(1);
});
-70
View File
@@ -1,70 +0,0 @@
const withTrailingSlash = (path) => {
if (!path || path === "/") return "/";
return path.endsWith("/") ? path : `${path}/`;
};
export const PUBLIC_ROUTES = [
{ path: "/", href: "/", label: "Accueil", prerender: true },
{
path: "/services/prestation-maitrise-oeuvre",
href: withTrailingSlash("/services/prestation-maitrise-oeuvre"),
label: "Création de site web",
prerender: true,
},
{
path: "/services/formations-web",
href: withTrailingSlash("/services/formations-web"),
label: "Formations web",
prerender: true,
},
{
path: "/services/electricite",
href: withTrailingSlash("/services/electricite"),
label: "Graphisme",
prerender: true,
},
{
path: "/services/service-securite-incendie",
href: withTrailingSlash("/services/service-securite-incendie"),
label: "Podcast",
prerender: true,
},
{
path: "/services/expertises-tce",
href: withTrailingSlash("/services/expertises-tce"),
label: "Formations professionnelles",
prerender: true,
},
{
path: "/nosFormations",
href: withTrailingSlash("/nosFormations"),
label: "Nos formations",
prerender: true,
},
{
path: "/posts",
href: withTrailingSlash("/posts"),
label: "Actualités",
prerender: true,
},
{
path: "/contact",
href: withTrailingSlash("/contact"),
label: "Contact",
prerender: true,
},
{
path: "/search",
href: withTrailingSlash("/search"),
label: "Recherche",
prerender: true,
},
{
path: "/sitemap.xml",
href: "/sitemap.xml",
label: "Plan du site complet",
prerender: false,
},
];
export const NOT_FOUND_ROUTE = "/__404";
-50
View File
@@ -1,50 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLIC_ROUTES } from "./routes.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const indexPath = path.resolve(__dirname, "../index.html");
const LIST_INDENT = " ";
const WRAPPER_INDENT = " ";
const generateListItems = () =>
PUBLIC_ROUTES.map(
({ path, href, label }) => {
const targetHref = href ?? path;
return `${LIST_INDENT}<li><a href="${targetHref}">${label}</a></li>`;
}
).join("\n");
async function main() {
const originalContent = await readFile(indexPath, "utf8");
const navPattern = new RegExp(
`${WRAPPER_INDENT}<ul class="no-js-nav">[\\s\\S]*?${WRAPPER_INDENT}<\\/ul>`,
"m"
);
const match = originalContent.match(navPattern);
if (!match) {
throw new Error(
"Bloc <ul class=\"no-js-nav\"> introuvable dans index.html. Impossible de mettre à jour la navigation noscript."
);
}
const replacement = `${WRAPPER_INDENT}<ul class="no-js-nav">\n${generateListItems()}\n${WRAPPER_INDENT}</ul>`;
const updatedContent = originalContent.replace(navPattern, replacement);
if (updatedContent !== originalContent) {
await writeFile(indexPath, updatedContent, "utf8");
console.log("✔ Mise à jour de la navigation noscript dans index.html");
} else {
console.log(" Navigation noscript déjà à jour");
}
}
main().catch((error) => {
console.error("[update-noscript] Échec de la mise à jour :", error);
process.exit(1);
});
+67
View File
@@ -0,0 +1,67 @@
import React, { useState, useEffect } from "react";
import { Box, Typography, Button, CircularProgress } from "@mui/material";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api";
const PostDetails = () => {
const { id } = useParams();
const [post, setPost] = useState(null);
const [image, setImage] = useState(null);
const navigate = useNavigate();
useEffect(() => {
const fetchPost = async () => {
try {
const response = await api.get(`wp/v2/posts/${id}?_fields=title,content,featured_media,date`);
setPost(response.data);
// Récupération de l'image
if (response.data.featured_media) {
const mediaResponse = await api.get(`wp/v2/media/${response.data.featured_media}`);
setImage(mediaResponse.data.source_url);
}
} catch (error) {
console.error("Erreur lors de la récupération de l'article :", error);
}
};
fetchPost();
}, [id]);
if (!post) {
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
}
return (
<Box sx={{ padding: "40px 20px",display:"flex", flexDirection:"column" }}>
<Button onClick={() => navigate(-1)} variant="outlined" sx={{ mt:5, mb: 3, maxWidth:"200px" }}>
Retour
</Button>
<Typography variant="h1" sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: '3rem', md: '3rem', lg: '3rem' }, // Responsive
}}>
{post.title.rendered}
</Typography>
<Box sx={{ display:"flex", alignItems:"center", justifyContent:"center", padding: "40px 20px",margin:"auto" }}>
{image && (
<Box
component="img"
src={image}
alt={post.title.rendered}
sx={{ width: "33%", maxHeight: "400px", objectFit: "cover", borderRadius: "8px", mb: 3 }}
/>
)}
<Typography variant="body1" sx={{ maxWidth: "800px", mx: "auto" }} dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</Box>
</Box>
);
};
export default PostDetails;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

-38
View File
@@ -1,38 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
.MuiTypography-h1 {
font-size: 3.2em;
line-height: 1.1;
font-family: "Nunito", sans-serif !important;
font-optical-sizing: auto;
font-weight: 800 !important;
font-style: normal;
}
/* h2,
span {
font-size: 2.2em;
line-height: 1.1;
font-family: "Nunito", sans-serif !important;
font-optical-sizing: auto;
font-weight: 700 !important;
font-style: normal;
color: #315397;
} */
/* .MuiTypography-h2{
color: #ffffff !important;
} */
/* .css-1ulggnf h2,
.MuiTypography-span,
.MuiTypography-h6{
color: #ffffff !important;
} */
/* .MuiBox-root .MuiTypography-h2{
color: #315397 !important
} */
-374
View File
@@ -1,374 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
h1 {
font-size: 1.6rem;
line-height: 1.1;
font-family: "Nunito", sans-serif !important;
font-optical-sizing: auto;
font-weight: 800 !important;
font-style: normal;
color: #315397 !important;
}
p,
h2,
h3,
h4,
h5{
font-family: "Nunito", sans-serif !important;
font-optical-sizing: auto;
font-style: normal;
}
.c4lv-readingcontext p,
.c4lv-readingcontext {
font-size: 16px;
line-height: 23px;
}
.c4lv-dodontcards .c4l-dodontcards-do {
min-width: 200px;
max-width: 100%;
background: #f1fbf5;
border-radius: 10px;
padding: 24px 48px 30px 36px;
margin: 12px auto;
position: relative;
}
.c4lv-dodontcards .c4l-dodontcards-do::before {
content: url(https://www.formations.octopusdesign.fr/theme/image.php/boost_magnific/tiny_c4l/1752797278/c4l_docard);
position: absolute;
top: 12px;
right: 12px;
}
.c4lv-dodontcards .c4l-dodontcards-dont {
min-width: 200px;
max-width: 100%;
background: #ffefef;
border-radius: 10px;
padding: 24px 48px 30px 36px;
margin: 12px auto;
position: relative;
}
.c4lv-dodontcards .c4l-dodontcards-dont::before {
content: url(https://www.formations.octopusdesign.fr/theme/image.php/boost_magnific/tiny_c4l/1752797278/c4l_dontcard);
position: absolute;
top: 12px;
right: 12px;
}
.c4lv-readingcontext p, .c4lv-readingcontext span, .c4lv-readingcontext li {
font-size: 16px;
line-height: 23px;
font-family: "Nunito", sans-serif !important;
font-optical-sizing: auto;
font-weight: 700 !important;
font-style: normal;
}
h3 {
font-size: 1.2em !important;
}
ul li {
list-style: inherit !important;
}
/* .css-1ulggnf h2,
.MuiTypography-span,
.MuiTypography-h6 {
color: #ffffff;
} */
.c4lv-remember {
background-color: #d7d9eb;
margin: 24px auto 8px auto;
padding: 24px 30px;
border: 2px solid #315397;
border-radius: 16px 0 16px 16px;
color: #315397;
font-family: Poppins, sans-serif;
font-size: 1rem;
font-weight: 400;
position: relative;
}
.c4lv-remember::before {
content: url(https://res.cloudinary.com/dh5qgexjo/image/upload/v1752833757/Formao_info.5a175e98_drna5l.svg)" À retenir";
display: block;
margin-bottom: 10px;
font-size: 1.5em;
font-weight: 600;
color: #315397;
}
.c4lv-readingcontext {
min-width: 200px;
max-width: 75%;
background-color: #fff;
box-shadow: 0 4px 24px rgb(0 0 0 / .08);
box-sizing: border-box;
margin: 36px auto;
}
.c4lv-readingcontext {
padding: 30px 40px 32px 40px;
font-family: sans-serif;
}
/* 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;
}
@@ -1,287 +0,0 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Typography, CircularProgress } from "@mui/material";
import { MoodleApi } from "../../services/moodleApi";
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
const CoursLecture = () => {
const { token } = useParams();
const [content, setContent] = useState(null);
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(true);
const [tokenValid, setTokenValid] = useState(true);
// ✅ 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 = 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 = previousUserSelect || "auto";
};
}, []);
// ✅ Récupération contenu
useEffect(() => {
const decodeToken = () => {
if (!token) {
return null;
}
try {
const raw = atob(token);
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
throw new Error("Invalid JSON payload");
}
const {
courseId,
itemType = "page",
itemId,
expiry,
linkId = null,
} = parsed;
if (!courseId || !itemId || !expiry) {
return null;
}
return {
courseId: String(courseId),
itemType: itemType || "page",
itemId: String(itemId),
expiry,
linkId,
format: "json",
};
} catch (parseError) {
console.warn("Lien public JSON invalide :", parseError);
const parts = raw.split("_");
if (parts.length === 3) {
const [courseId, itemId, expiry] = parts;
return {
courseId,
itemType: "page",
itemId,
expiry,
linkId: null,
format: "legacy",
};
}
if (parts.length === 4) {
const [courseId, itemType, itemId, expiry] = parts;
return {
courseId,
itemType,
itemId,
expiry,
linkId: null,
format: "legacy",
};
}
return null;
}
} catch (decodeError) {
console.warn("Impossible de décoder le lien sécurisé :", decodeError);
return null;
}
};
const decoded = decodeToken();
if (!decoded) {
setTokenValid(false);
setContent("⛔ Lien invalide.");
setLoading(false);
return;
}
const { courseId, itemType, itemId, expiry, format } = decoded;
const expiryValue = Number(expiry);
if (
!courseId ||
!itemId ||
!Number.isFinite(expiryValue) ||
Date.now() > expiryValue
) {
setTokenValid(false);
setContent("⛔ Le cours est terminé !");
setLoading(false);
return;
}
const normalizedItemType = itemType || "page";
const linkStillActive = () => {
if (format === "legacy") {
return true;
}
if (typeof window === "undefined") {
return true;
}
try {
const stored = localStorage.getItem(PUBLIC_LINKS_STORAGE_KEY);
if (!stored) {
return false;
}
const registry = JSON.parse(stored);
const key = `${courseId}-${normalizedItemType}-${itemId}`;
const entry = registry?.[key];
if (!entry || !entry.active) {
return false;
}
if (entry.token && entry.token !== token) {
return false;
}
if (
entry.expiry !== undefined &&
Number.isFinite(Number(entry.expiry)) &&
Date.now() > Number(entry.expiry)
) {
return false;
}
return true;
} catch (error) {
console.error("Impossible de vérifier l'état du lien public :", error);
return false;
}
};
if (!linkStillActive()) {
setTokenValid(false);
setContent("⛔ Ce lien n'est plus actif.");
setLoading(false);
return;
}
const fetchPage = async () => {
try {
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 || "");
} else {
setTokenValid(false);
setContent("⛔ Page non trouvée.");
}
} catch (error) {
console.error("Erreur lors du chargement de la page Moodle :", error);
setTokenValid(false);
setContent("❌ Erreur lors du chargement.");
} finally {
setLoading(false);
}
};
const fetchAssignment = async () => {
try {
const assignments = await MoodleApi.getAssignmentsByCourse(
Number(courseId)
);
const parsedAssignmentId = parseInt(itemId, 10);
const assignment = assignments.find(
(a) => a.id === parsedAssignmentId
);
if (assignment) {
setTitle(assignment.name || "");
setContent(assignment.intro || "<p>Aucun contenu disponible.</p>");
} else {
setTokenValid(false);
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 {
setLoading(false);
}
};
if (normalizedItemType === "assignment") {
fetchAssignment();
} else {
fetchPage();
}
}, [token]);
if (loading) {
return (
<Box
sx={{
padding: 4,
textAlign: "center",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
>
<CircularProgress />
</Box>
);
}
if (!tokenValid) {
return (
<Box
sx={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f8d7da",
}}
>
<Typography
variant="h3"
sx={{
color: "#721c24",
fontWeight: "bold",
textAlign: "center",
padding: 4,
}}
>
{content}
</Typography>
</Box>
);
}
return (
<Box sx={{ padding: 4, mt: 5 }}>
{title ? (
<Typography variant="h4" sx={{ mb: 3, color: "#315397" }}>
{title}
</Typography>
) : null}
<div dangerouslySetInnerHTML={{ __html: content }} />
</Box>
);
};
export default CoursLecture;
@@ -1,817 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
CircularProgress,
Divider,
Grid,
IconButton,
List,
ListItem,
ListItemText,
Stack,
TextField,
Typography,
} from "@mui/material";
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 defaultCourseForm = {
fullname: "",
shortname: "",
categoryid: "1",
summary: "",
};
const defaultSectionForm = {
name: "",
summary: "",
section: "",
};
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 mapCourseToForm = (course) => ({
fullname: course.fullname || "",
shortname: course.shortname || "",
categoryid: String(course.categoryid || "1"),
summary: course.summary || "",
});
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 {
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 {
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={{ 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>
{!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
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>
<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 GestionCours;
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import React, { useEffect, useState } from "react";
import axios from "axios";
import { Box, Typography, ImageList, ImageListItem } from "@mui/material";
const BACKEND_URL = "https://api.sveitl.synology.me/api/cloudinary-images"; // adapte pour prod
const CloudinaryGallerySelector = ({ onSelect }) => {
const [images, setImages] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const BACKEND_URL = import.meta.env.VITE_CLOUDINARY_BACKEND_URL;
useEffect(() => {
const fetchImages = async () => {
try {
const response = await axios.get(`${BACKEND_URL}/api/cloudinary-images`);
const data = response.data;
// 🔐 sécurisation du format de réponse
const validImages = Array.isArray(data)
? data
: Array.isArray(data.resources)
? data.resources
: [];
if (validImages.length === 0) {
setError("Aucune image trouvée.");
}
setImages(validImages);
} catch (err) {
console.error("❌ Erreur Cloudinary complète :", JSON.stringify(err.response?.data || err.message, null, 2));
setError("Impossible de récupérer les images depuis le serveur.");
} finally {
setLoading(false);
}
};
fetchImages();
}, []);
if (loading) return <Typography>Chargement des images Cloudinary...</Typography>;
if (error) return <Typography color="error">{error}</Typography>;
return (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
Galerie Cloudinary :
</Typography>
<ImageList cols={3} gap={12}>
{images.map((img) => (
<ImageListItem key={img.public_id} onClick={() => onSelect(img.secure_url)}>
<img
src={img.secure_url.replace("/upload/", "/upload/f_webp,w_300/")}
alt={img.public_id}
loading="lazy"
style={{
cursor: "pointer",
borderRadius: 8,
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
}}
/>
</ImageListItem>
))}
</ImageList>
</Box>
);
};
export default CloudinaryGallerySelector;
+47 -107
View File
@@ -1,16 +1,12 @@
import React, { useState, useEffect, useMemo } from "react";
import React from "react";
import {
Box,
Typography,
Button,
Grid,
Card,
CardContent,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import api from "../api";
const ConstructSection = ({
constructTitle,
@@ -21,59 +17,26 @@ const ConstructSection = ({
missionTitle,
missionItems,
}) => {
const [expanded, setExpanded] = useState(false);
const [missions, setMissions] = useState(
Array.isArray(missionItems) && missionItems.length ? missionItems : []
);
const defaultMissions = useMemo(
() => [
{ title: "📌 Mission 1", details: "Détails de la mission 1" },
{ title: "📌 Mission 2", details: "Détails de la mission 2" },
{ title: "📌 Mission 3", details: "Détails de la mission 3" },
],
[]
);
useEffect(() => {
const hydrateMissions = async () => {
if (Array.isArray(missionItems) && missionItems.length) {
setMissions(missionItems);
return;
}
try {
const response = await api.get("wp/v2/pages/13?_fields=acf");
const fetched = response.data.acf?.mission;
if (Array.isArray(fetched) && fetched.length) {
setMissions(fetched);
} else {
setMissions(defaultMissions);
}
} catch (error) {
console.error("❌ Erreur lors du chargement des missions :", error);
setMissions(defaultMissions);
}
};
hydrateMissions();
}, [missionItems, defaultMissions]);
// ✅ Gestion des accordéons
const handleAccordionChange = (panel) => (event, isExpanded) => {
setExpanded(isExpanded ? panel : false);
};
return (
<Box sx={{ py: 6, px: 4, backgroundColor: "#f9f9f9" }}>
<Box
sx={{
py: 6,
px: 4,
backgroundColor: "#f9f9f9",
}}
>
<Grid container spacing={4}>
{/* Left Column */}
<Grid item xs={12} md={6}>
<Box>
{/* Titre et sous-titre */}
<Typography
variant="h2"
sx={{
fontWeight: "bold",
fontSize: { xs: "2rem", md: "2.4rem", lg: "3rem" },
display: "flex",
flexDirection: "column",
}}
>
{constructTitle}{" "}
@@ -81,6 +44,7 @@ const ConstructSection = ({
component="span"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
color: "#0e467f",
fontWeight: "bold",
}}
@@ -89,9 +53,14 @@ const ConstructSection = ({
</Typography>
</Typography>
{/* Secteurs */}
<Typography
variant="body1"
sx={{ mt: 2, fontWeight: "bold", color: "#0e467f" }}
sx={{
mt: 2,
fontWeight: "bold",
color: "#0e467f",
}}
>
Secteurs : {constructSector}
</Typography>
@@ -107,12 +76,13 @@ const ConstructSection = ({
dangerouslySetInnerHTML={{ __html: constructText }}
/>
{/* Cadre résumé */}
<Card
sx={{
mt: 4,
backgroundColor: "#0e467f",
color: "#fff",
borderRadius: "12px 0 12px 12px",
borderRadius: "12px",
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
}}
>
@@ -135,66 +105,36 @@ const ConstructSection = ({
<Grid item xs={12} md={6}>
<Box
sx={{
backgroundColor: "#fff",
borderRadius: "12px",
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
overflow: "hidden",
position: "relative",
height: "400px",
backgroundImage: `url('https://picsum.photos/id/52/600/400.webp')`, // Replace with the image URL or dynamic data
backgroundSize: "cover",
backgroundPosition: "center",
padding: 3,
borderRadius: "15px",
}}
>
{/* ✅ Correction de la duplication du titre */}
{missionTitle && (
<Typography
component="h3"
variant="h4"
sx={{
fontWeight: "bold",
textAlign: "center",
mt: 3,
mb: 2,
color: "#0e467f",
}}
>
{missionTitle}
</Typography>
)}
<Typography
variant="h3"
sx={{
fontWeight: "bold",
textAlign: "center",
mt: 3,
mb: 2,
color: "#0e467f",
}}
>
{missionTitle}
</Typography>
{/* ✅ Accordéon dynamique */}
{missions.length > 0 ? (
missions.map((mission, index) => (
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
component="p"
variant="subtitle1"
sx={{
fontWeight: "bold",
fontSize: { xs: "1rem", md: "1.2rem", lg: "1.5rem" },
}}
>
{mission.title}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{mission.details}</Typography>
</AccordionDetails>
</Accordion>
))
) : (
<Typography variant="body2" color="textSecondary">
Aucune mission définie.
</Typography>
)}
{/* Background Image et Items */}
<Box sx={{ position: "relative", height: "auto", mt: 3 }}>
{missionItems?.map((item, index) => (
<Box
sx={{
position: "relative",
height: "400px",
backgroundImage: `url('https://via.placeholder.com/600x400')`, // Replace with the image URL or dynamic data
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
{missionItems.map((item, index) => (
<Box
key={index}
sx={{
+24 -546
View File
@@ -1,555 +1,33 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getPostById, updatePost, uploadImageFromUrl } from "../wordpress";
import { getToken } from "../auth";
import {
Box,
Typography,
TextField,
Button,
Grid,
Card,
CardContent,
Stack,
Chip,
Divider,
} from "@mui/material";
import { ArrowBack, Publish, Image as ImageIcon, History } from "@mui/icons-material";
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
import RichTextEditor from "./common/RichTextEditor";
import "../assets/styleCours.css";
import { useState } from "react";
import { updatePost } from "../wordpress";
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)",
},
});
function EditPost({ post }) {
const [title, setTitle] = useState(post.title.rendered);
const [content, setContent] = useState(post.content.rendered);
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.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 EditPost() {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageUrl, setImageUrl] = useState(null); // preview
const [imageFile, setImageFile] = useState(null); // pour upload
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [postMeta, setPostMeta] = useState({
date: null,
modified: null,
status: "",
});
// Auth
useEffect(() => {
if (!getToken()) navigate("/admin/login");
}, [navigate]);
// Article
useEffect(() => {
const fetchPost = async () => {
try {
setLoading(true);
const post = await getPostById(id);
setTitle(post.title.rendered);
setContent(post.content.rendered);
setImageUrl(post?.jetpack_featured_media_url || null);
setPostMeta({
date: post.date || null,
modified: post.modified || null,
status: post.status || "",
});
} catch (error) {
console.error("Erreur chargement article :", error);
navigate("/admin/gestion-articles");
} finally {
setLoading(false);
}
};
fetchPost();
}, [id, navigate]);
// 🧠 convertit URL en File
const convertUrlToFile = async (url) => {
const res = await fetch(url);
const blob = await res.blob();
return new File([blob], "image-cloudinary.jpg", { type: blob.type });
};
const toolbarOptions = [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
["link", "image"],
["clean"],
];
const canSubmit =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
const handleUpdate = async (event) => {
event.preventDefault();
if (!canSubmit || submitting) {
return;
}
try {
setSubmitting(true);
let imageId = undefined;
if (imageFile || imageUrl) {
imageId = await uploadImageFromUrl(imageUrl);
}
await updatePost(id, title, content, imageId);
alert("✅ Article mis à jour !");
navigate("/admin/gestion-articles");
} catch (error) {
console.error("❌ Erreur update :", error);
alert("❌ Erreur mise à jour.");
} finally {
setSubmitting(false);
const handleUpdate = async () => {
const success = await updatePost(post.id, title, content);
if (success) {
alert("Article mis à jour !");
} else {
alert("Échec de la mise à jour.");
}
};
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
onClick={() => navigate("/admin/gestion-articles")}
variant="contained"
startIcon={<ArrowBack />}
sx={composeButtonSx("ghost")}
>
Retour aux articles
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Publication Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Modifier larticle
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Ajustez votre contenu, remplacez le visuel mis en avant et publiez
les mises à jour directement sur le site Octopus.
</Typography>
</Stack>
</Stack>
<Grid container spacing={3.5}>
<Grid item xs={12} lg={7}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardContent sx={{ p: 0 }}>
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: "wrap" }}
>
<Chip
icon={<History fontSize="small" />}
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,
}}
/>
<Chip
label={
postMeta.status === "publish"
? "Publiée"
: postMeta.status === "draft"
? "Brouillon"
: postMeta.status || "Statut inconnu"
}
color={
postMeta.status === "publish"
? "success"
: postMeta.status === "draft"
? "warning"
: "default"
}
sx={{ fontWeight: 600 }}
/>
</Stack>
{loading ? (
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Chargement de larticle
</Typography>
) : (
<Box component="form" onSubmit={handleUpdate} noValidate>
<TextField
label="Titre de l'article"
fullWidth
variant="outlined"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<RichTextEditor
value={content}
onChange={setContent}
minHeight={280}
toolbarOptions={toolbarOptions}
/>
</Box>
<Stack spacing={2.5}>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Remplacer le visuel
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Importez une nouvelle image optimisée pour vos
contenus.
</Typography>
<Box sx={{ mt: 2 }}>
<ImageUploaderCloudinary
onUploadSuccess={(url) => {
setImageUrl(url);
convertUrlToFile(url).then(setImageFile);
}}
/>
</Box>
</Box>
<Divider
sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }}
/>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Sélectionner depuis la médiathèque
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 2 }}
>
Choisissez un visuel existant dans Cloudinary.
</Typography>
<CloudinaryGallerySelector
onSelect={async (url) => {
setImageUrl(url);
const file = await convertUrlToFile(url);
setImageFile(file);
}}
/>
</Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ pt: 1 }}
>
<Button
type="submit"
variant="contained"
startIcon={<Publish />}
disabled={!canSubmit || submitting}
sx={{
...composeButtonSx("primary"),
minWidth: 220,
}}
>
{submitting ? "Enregistrement…" : "Mettre à jour"}
</Button>
<Button
variant="contained"
sx={composeButtonSx("outline")}
onClick={() => {
setTitle("");
setContent("");
setImageUrl(null);
setImageFile(null);
}}
>
Réinitialiser
</Button>
</Stack>
</Stack>
</Box>
)}
</Stack>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
bgcolor: "rgba(123,192,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
}}
>
<ImageIcon />
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d", mb: 1 }}
>
Rappels éditoriaux
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)" }}
>
Vérifiez la cohérence des titres et sous-titres.<br />
Ajoutez des liens internes vers vos formations Octopus.<br />
Noubliez pas de relire la mise en forme dans laperçu
WordPress.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel illustrera larticle dans la liste des publications
et sur les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
<div>
<h1>Modifier larticle</h1>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<button onClick={handleUpdate}>Mettre à jour</button>
</div>
);
}
+199 -173
View File
@@ -1,134 +1,8 @@
import { useState, useRef, useEffect } from "react";
import React, { useState } from "react";
import { Box, Grid, Typography, Card, CardContent, Modal } from "@mui/material";
import pictoMaitriseOeuvre from "../assets/picto-maitrise-oeuvre.avif";
import pictoStructure from "../assets/picto-structure.avif";
import pictoElectricite from "../assets/picto-electricite.avif";
import PropTypes from "prop-types";
// Style commun pour le contenu du modal
const modalStyle = {
padding: 4,
borderRadius: "20px",
background: "rgba(255, 255, 255, 0.95)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(10px)",
maxWidth: 600,
margin: "auto",
mt: 4,
position: "relative",
maxHeight: "80vh",
};
// Style commun pour l'icône affichée en haut à droite dans le modal
const iconStyle = {
position: "absolute",
top: 16,
right: 16,
width: 80,
height: 80,
backgroundSize: "cover",
backgroundPosition: "center",
borderRadius: "50%",
};
// Style commun pour les boutons "En savoir plus" et "Retour"
const buttonStyle = {
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
};
// Fonction pour générer le style de la carte en fonction de l'image de fond
const cardStyle = (image) => ({
boxShadow: 3,
padding: 2,
textAlign: "center",
backgroundColor: "#fff",
backgroundImage: `linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)), url(${image})`,
backgroundSize: "40%",
backgroundRepeat: "no-repeat",
backgroundPosition: "center left",
});
const ScrollableContent = ({ text }) => {
const contentRef = useRef(null);
const [showHint, setShowHint] = useState(false);
useEffect(() => {
const node = contentRef.current;
if (!node) return;
const updateHintVisibility = () => {
const needsScroll = node.scrollHeight > node.clientHeight;
const nearBottom =
node.scrollTop >= node.scrollHeight - node.clientHeight - 12;
setShowHint(needsScroll && !nearBottom);
};
updateHintVisibility();
node.addEventListener("scroll", updateHintVisibility);
return () => node.removeEventListener("scroll", updateHintVisibility);
}, [text]);
return (
<Box sx={{ position: "relative" }}>
<Typography
ref={contentRef}
variant="body1"
sx={{
color: "#333",
lineHeight: 1.6,
maxHeight: "60vh",
overflowY: "auto",
pr: 1,
}}
dangerouslySetInnerHTML={{ __html: text }}
/>
{showHint && (
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
pt: 4,
textAlign: "center",
background:
"linear-gradient(rgba(255,255,255,0), rgba(255,255,255,0.95))",
}}
>
<Typography
variant="caption"
sx={{ color: "#0e467f", fontWeight: 600, letterSpacing: 0.5 }}
>
Faites défiler pour tout lire
</Typography>
</Box>
)}
</Box>
);
};
// Composant Modal réutilisable
const ExpertiseModal = ({ modalId, image, title, text, openModal, handleClose }) => (
<Modal open={openModal === modalId} onClose={handleClose}>
<Box sx={{ ...modalStyle, maxHeight: "80vh" }}>
<Box sx={{ ...iconStyle, backgroundImage: `url(${image})` }} />
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
{title}
</Typography>
<ScrollableContent text={text} />
<button onClick={handleClose} style={buttonStyle}>
Retour
</button>
</Box>
</Modal>
);
const Expertices = ({
expertiseTitle,
@@ -141,12 +15,16 @@ const Expertices = ({
}) => {
const [openModal, setOpenModal] = useState(null);
const handleOpenModal = (modalId) => setOpenModal(modalId);
const handleCloseModal = () => setOpenModal(null);
const handleOpenModal = (modalId) => {
setOpenModal(modalId);
};
const handleCloseModal = () => {
setOpenModal(null);
};
// Fonction qui supprime les balises HTML et tronque le texte s'il est trop long
const truncateText = (text, maxLength) => {
const plainText = text.replace(/<[^>]+>/g, "");
const plainText = text.replace(/<[^>]+>/g, ""); // Supprime les balises HTML
return plainText.length > maxLength
? `${plainText.substring(0, maxLength)}...`
: plainText;
@@ -169,7 +47,21 @@ const Expertices = ({
<Grid container spacing={4}>
{/* Expertise 1 */}
<Grid item xs={12} md={4}>
<Card sx={cardStyle(pictoMaitriseOeuvre)}>
<Card
sx={{
backgroundPosition: "center",
boxShadow: 3,
padding: 2,
textAlign: "center",
backgroundColor: "#fff",
backgroundImage: `
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
url(${pictoMaitriseOeuvre})`,
backgroundSize: "40%",
backgroundRepeat: "no-repeat",
backgroundPosition: "center left",
}}
>
<CardContent>
<Typography
variant="h3"
@@ -186,7 +78,16 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("maitrise_oeuvre")}
style={buttonStyle}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
>
En savoir plus
</button>
@@ -196,7 +97,21 @@ const Expertices = ({
{/* Expertise 2 */}
<Grid item xs={12} md={4}>
<Card sx={cardStyle(pictoStructure)}>
<Card
sx={{
backgroundPosition: "center",
boxShadow: 3,
padding: 2,
textAlign: "center",
backgroundColor: "#fff",
backgroundImage: `
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
url(${pictoStructure})`,
backgroundSize: "40%",
backgroundRepeat: "no-repeat",
backgroundPosition: "center left",
}}
>
<CardContent>
<Typography
variant="h3"
@@ -213,7 +128,16 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("structure")}
style={buttonStyle}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
>
En savoir plus
</button>
@@ -223,7 +147,20 @@ const Expertices = ({
{/* Expertise 3 */}
<Grid item xs={12} md={4}>
<Card sx={cardStyle(pictoElectricite)}>
<Card
sx={{
backgroundPosition: "center",
boxShadow: 3,
padding: 2,
textAlign: "center",
backgroundColor: "#fff",
backgroundImage: `linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
url(${pictoElectricite})`,
backgroundSize: "40%",
backgroundRepeat: "no-repeat",
backgroundPosition: "center left",
}}
>
<CardContent>
<Typography
variant="h3"
@@ -240,7 +177,16 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("electricite")}
style={buttonStyle}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
>
En savoir plus
</button>
@@ -249,44 +195,124 @@ const Expertices = ({
</Grid>
</Grid>
{/* Modaux */}
<ExpertiseModal
modalId="maitrise_oeuvre"
image={pictoMaitriseOeuvre}
title={expertise1Title}
text={expertise1Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
<ExpertiseModal
modalId="structure"
image={pictoStructure}
title={expertise2Title}
text={expertise2Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
<ExpertiseModal
modalId="electricite"
image={pictoElectricite}
title={expertise3Title}
text={expertise3Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
{/* Modal 1 */}
<Modal open={openModal === "maitrise_oeuvre"} onClose={handleCloseModal}>
<Box
sx={{
padding: 4,
borderRadius: "20px",
background: "rgba(255, 255, 255, 0.9)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(10px)",
maxWidth: 600,
margin: "auto",
mt: 4,
position: "relative",
}}
>
<Box
sx={{
position: "absolute",
top: 16,
right: 16,
width: 80,
height: 80,
backgroundImage: `url(${pictoMaitriseOeuvre})`,
backgroundSize: "cover",
backgroundPosition: "center",
borderRadius: "50%",
}}
/>
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
{expertise1Title}
</Typography>
<Typography
variant="body1"
sx={{ color: "#333", lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: expertise1Text }}
/>
</Box>
</Modal>
{/* Modal 2 */}
<Modal open={openModal === "structure"} onClose={handleCloseModal}>
<Box
sx={{
padding: 4,
borderRadius: "20px",
background: "rgba(255, 255, 255, 0.9)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(10px)",
maxWidth: 600,
margin: "auto",
mt: 4,
position: "relative",
}}
>
<Box
sx={{
position: "absolute",
top: 16,
right: 16,
width: 80,
height: 80,
backgroundImage: `url(${pictoStructure})`,
backgroundSize: "cover",
backgroundPosition: "center",
borderRadius: "50%",
}}
/>
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
{expertise2Title}
</Typography>
<Typography
variant="body1"
sx={{ color: "#333", lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: expertise2Text }}
/>
</Box>
</Modal>
{/* Modal 3 */}
<Modal open={openModal === "electricite"} onClose={handleCloseModal}>
<Box
sx={{
padding: 4,
borderRadius: "20px",
background: "rgba(255, 255, 255, 0.9)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(10px)",
maxWidth: 600,
margin: "auto",
mt: 4,
position: "relative",
}}
>
<Box
sx={{
position: "absolute",
top: 16,
right: 16,
width: 80,
height: 80,
backgroundImage: `url(${pictoElectricite})`,
backgroundSize: "cover",
backgroundPosition: "center",
borderRadius: "50%",
}}
/>
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
{expertise3Title}
</Typography>
<Typography
variant="body1"
sx={{ color: "#333", lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: expertise3Text }}
/>
</Box>
</Modal>
</Box>
);
};
Expertices.propTypes = {
expertiseTitle: PropTypes.string.isRequired,
expertise1Title: PropTypes.string.isRequired,
expertise1Text: PropTypes.string.isRequired,
expertise2Title: PropTypes.string.isRequired,
expertise2Text: PropTypes.string.isRequired,
expertise3Title: PropTypes.string.isRequired,
expertise3Text: PropTypes.string.isRequired,
};
export default Expertices;
+2 -3
View File
@@ -64,13 +64,12 @@ const Faq = ({ showFAQ }) => {
}}
>
<Typography
variant="body2"
variant="h3"
sx={{
fontWeight: "bold",
mb: 4,
textAlign: "center",
color: "#0e467f",
fontSize: { xs: "2rem", md: "2rem", lg: "2.5rem",whiteSpace: "pre-line" }
}}
>
FAQ
@@ -103,7 +102,7 @@ const Faq = ({ showFAQ }) => {
{filteredFaqs.length > 0 ? (
filteredFaqs.map((faq, index) => (
<Box key={index} sx={{ mb: 3 }}>
<Typography variant="h3" sx={{ fontWeight: "bold",fontSize: { xs: "2rem", md: "2rem", lg: "2rem" } }}>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
{faq.question}
</Typography>
<Typography
+13 -8
View File
@@ -16,7 +16,6 @@ import {
const Footer = () => {
return (
<Box
component="footer"
sx={{
backgroundColor: "#0e467f",
color: "white",
@@ -34,30 +33,36 @@ const Footer = () => {
<Typography
variant="h6"
component="h2"
sx={{ fontWeight: "bold", color:"#ffffff", textAlign: { xs: "center", md: "left" } }}
sx={{ fontWeight: "bold", textAlign: { xs: "center", md: "left" } }}
>
Octopusdesign
IN3 Intégrale Ingénierie Internationale
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
Route du Perrier,
67 Boulevard Winston Churchill,
<br />
85270 - Saint Hilaire de Riez
72100 Le Mans, France
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
<strong>Téléphone :</strong> 06.00.00.00.00
<strong>Téléphone :</strong> 02.43.85.09.01
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
<strong>Email :</strong>{" "}
contact@octopusdesign.fr
<Link
href="mailto:contact@be-in3.com"
underline="hover"
sx={{ color: "white" }}
>
contact@be-in3.com
</Link>
</Typography>
</Grid>
@@ -187,7 +192,7 @@ const Footer = () => {
}}
>
<Typography variant="body2">
© {new Date().getFullYear()} Octopusdesign.
© {new Date().getFullYear()} IN3 Intégrale Ingénierie Internationale.
Tous droits réservés.
</Typography>
</Box>
@@ -1,669 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { getToken } from "../auth";
import { getPageById, updatePageACF } from "../wordpress";
import {
Box,
Typography,
TextField,
Button,
Grid,
Card,
CardContent,
Stack,
Divider,
CircularProgress,
} from "@mui/material";
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
import RichTextEditor from "./common/RichTextEditor";
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(() => {
if (!getToken()) navigate("/admin/login");
}, [navigate]);
// Récupère les champs ACF
useEffect(() => {
const fetchPage = async () => {
try {
const pageData = await getPageById(ETUDE_PAGE_ID);
if (!pageData?.acf) {
setError("❌ Impossible de charger les champs ACF.");
return;
}
console.log("expertises_specifiques:", pageData.acf.expertises_specifiques);
setAcfFields(pageData.acf);
} catch {
setError("⚠ Impossible de récupérer les champs ACF.");
} finally {
setLoading(false);
}
};
fetchPage();
}, []);
// Mise à jour ACF
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"), 1800);
} catch (err) {
console.error("⚠ Impossible de mettre à jour les champs ACF :", err);
setError("⚠ Impossible de mettre à jour les champs ACF.");
} finally {
setSubmitting(false);
}
};
// Modifie un champ simple
const handleFieldChange = (field, value) => {
setAcfFields(prev => ({ ...prev, [field]: value }));
};
// Normalise un repeater en tableau
const getRepeater = (field) => {
const data = acfFields[field];
return Array.isArray(data)
? data
: data
? Object.values(data)
: [];
};
// Modifie un item du repeater
const handleRepeaterChange = (field, index, subField, value) => {
const list = getRepeater(field);
const updated = [...list];
updated[index] = { ...updated[index], [subField]: value };
setAcfFields(prev => ({ ...prev, [field]: updated }));
};
// Ajoute un item au repeater
const addRepeaterItem = (field, template) => {
const list = getRepeater(field);
setAcfFields(prev => ({ ...prev, [field]: [...list, template] }));
};
// Supprime un item du repeater
const deleteRepeaterItem = (field, index) => {
const list = getRepeater(field);
const updated = list.filter((_, i) => i !== index);
setAcfFields(prev => ({ ...prev, [field]: updated }));
};
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 (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Stack spacing={2} alignItems="center" sx={{ position: "relative", zIndex: 1 }}>
<CircularProgress color="inherit" />
<Typography variant="body1" sx={{ color: "#ffffff" }}>
Chargement de la page Bureau détude
</Typography>
</Stack>
</Box>
);
}
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate("/admin/dashboard")}
variant="contained"
sx={composeButtonSx("ghost")}
>
Retour au dashboard
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Bureau détude Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Gestion des contenus ACF
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Mettez à jour lintroduction, la méthodologie et les expertises du
bureau détude pour garder des informations pertinentes et à jour.
</Typography>
</Stack>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Compétences listées
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.competencies}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Ajoutez des compétences pour valoriser lexpertise Octopus.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.expertises}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Développez vos expertises pour renforcer la crédibilité de léquipe.
</Typography>
</Card>
</Grid>
</Grid>
{error ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(240,91,107,0.35) !important" }}
>
<Typography color="error" sx={{ fontWeight: 600 }}>
{error}
</Typography>
</Card>
) : null}
{successMessage ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(123,192,255,0.45) !important" }}
>
<Typography sx={{ color: "#0b8043", fontWeight: 600 }}>
{successMessage}
</Typography>
</Card>
) : null}
<Box component="form" onSubmit={handleUpdateACF}>
<Stack spacing={3.5}>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Introduction
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Présentez la vision globale du bureau détude et son rôle
au sein des formations Octopus.
</Typography>
<Box
sx={{
borderRadius: 2,
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<RichTextEditor
value={acfFields.introduction || ""}
onChange={(value) => handleFieldChange("introduction", value)}
minHeight={220}
/>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }} />
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Méthodologie
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez en quelques lignes la manière dont le bureau
détude accompagne les clients et partenaires.
</Typography>
<TextField
fullWidth
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)",
},
}}
/>
</Box>
</Stack>
</CardContent>
</Card>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Liste des compétences
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Ajoutez les compétences clés qui démontrent lexpertise du
bureau détude. Chaque compétence peut contenir un titre et
une description détaillée.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("liste_des_competences").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Titre"
fullWidth
value={item.comp_titre}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_titre",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Description"
fullWidth
multiline
minRows={3}
value={item.comp_description}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_description",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("liste_des_competences", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Stack>
</Card>
))}
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("liste_des_competences", {
comp_titre: "",
comp_description: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une compétence
</Button>
</Stack>
</CardContent>
</Card>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez les expertises métiers ou sectorielles qui
distinguent votre bureau détude.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("expertises_specifiques").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Nom de lexpertise"
fullWidth
value={item.expertise_nom}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_nom",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Détails"
fullWidth
multiline
minRows={2}
value={item.expertise_details}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_details",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("expertises_specifiques", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Stack>
</Card>
))}
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("expertises_specifiques", {
expertise_nom: "",
expertise_details: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une expertise
</Button>
</Stack>
</CardContent>
</Card>
<Button
type="submit"
variant="contained"
startIcon={<Save />}
disabled={submitting}
sx={{ ...composeButtonSx("primary"), alignSelf: "flex-end", minWidth: 220 }}
>
{submitting ? "Enregistrement…" : "Enregistrer les modifications"}
</Button>
</Stack>
</Box>
</Box>
</Box>
);
}
export default EditPageEtude;
-483
View File
@@ -1,483 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { getToken } from "../auth";
import { fetchPages } from "../wordpress";
import {
Box,
Typography,
Button,
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(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
// ✅ Récupérer les pages ACF et exclure la page d'accueil
useEffect(() => {
const loadPages = async () => {
try {
const response = await fetchPages();
console.log("📢 Données reçues de fetchPages() :", response);
if (!Array.isArray(response)) {
console.error(
"❌ La réponse `fetchPages()` n'est pas un tableau :",
response
);
setError("❌ Impossible de charger les pages ACF.");
setLoading(false);
return;
}
const filteredPages = response.filter(
(page) => page.id !== HOME_PAGE_ID && page.id !== ETUDE_PAGE_ID,
);
setPages(filteredPages);
} catch (error) {
console.error("❌ Erreur chargement pages ACF :", error);
setError("Impossible de charger les pages.");
} finally {
setLoading(false);
}
};
loadPages();
}, []);
const filteredPages = useMemo(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) {
return pages;
}
return pages.filter((page) =>
page.title?.rendered?.toLowerCase?.().includes(term),
);
}, [pages, searchTerm]);
if (loading) {
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Stack spacing={2} alignItems="center" sx={{ position: "relative", zIndex: 1 }}>
<CircularProgress color="inherit" />
<Typography variant="body1" sx={{ color: "#ffffff" }}>
Chargement des pages services
</Typography>
</Stack>
</Box>
);
}
if (error) {
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box
sx={{
position: "relative",
zIndex: 1,
maxWidth: 720,
mx: "auto",
mt: 6,
}}
>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: 3,
borderColor: "rgba(240,91,107,0.32) !important",
}}
>
<Typography color="error" sx={{ fontWeight: 600 }}>
{error}
</Typography>
</Card>
</Box>
</Box>
);
}
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="contained"
onClick={() => navigate("/admin/dashboard")}
sx={composeButtonSx("ghost")}
>
Retour au dashboard
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Gestion des pages services
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Contenus ACF disponibles
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Accédez rapidement aux pages service Octopus équipées de champs
ACF pour les mettre à jour en quelques clics.
</Typography>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
window.open("https://it.sveitl.synology.me/", "_blank")
}
sx={composeButtonSx("primary")}
>
Ouvrir un ticket
</Button>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Pages référencées
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{pages.length}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Ensemble des pages service disposant de champs ACF personnalisés.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Pages visibles
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{filteredPages.length}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Résultats actuels après filtrage par titre.
</Typography>
</Card>
</Grid>
</Grid>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
mb: 4,
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.48)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
Rechercher une page
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Filtrez par titre pour trouver une page service en un instant.
</Typography>
</Box>
<TextField
label="Rechercher..."
variant="outlined"
fullWidth
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
sx={{
maxWidth: 360,
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
</Stack>
</Card>
{filteredPages.length === 0 ? (
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: "#0b1a3d" }}>
Aucune page trouvée
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 1 }}
>
Ajustez votre recherche ou vérifiez que la page dispose bien de
champs ACF activés.
</Typography>
</Card>
) : (
<Grid container spacing={2.5}>
{filteredPages.map((page, index) => (
<Grid
item
xs={12}
md={6}
lg={4}
key={page.id}
sx={{
mb:2,
mt: {
xs: index >= 1 ? 2 : 0,
md: index >= 3 ? 4 : 0,
},
}}
>
<Card
className="glass-card"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
boxShadow: "0 32px 44px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={1.8}>
<Typography
variant="overline"
sx={{ color: "rgba(11, 26, 61, 0.65)", letterSpacing: 1, }}
>
</Typography>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{page.title.rendered}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
>
Cliquez sur &quot;Modifier&quot; pour ajuster les contenus
de cette page service depuis léditeur ACF dédié.
</Typography>
</Stack>
<Button
startIcon={<Edit />}
variant="contained"
sx={composeButtonSx("outline", "small")}
onClick={() => navigate(`/admin/edit-page/${page.id}`)}
>
Modifier
</Button>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
</Box>
);
}
export default GestionPagesACF;
+25 -76
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import React, { useState } from "react";
import {
AppBar,
Toolbar,
@@ -24,8 +24,8 @@ import ArticleIcon from "@mui/icons-material/Article";
import InfoIcon from "@mui/icons-material/Info";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import WorkIcon from "@mui/icons-material/Work";
import Logo from "../assets/centre-formation-logo.avif";
import LogoM from "../assets/centre-formation-logo.avif";
import Logo from "../assets/logo-in3.svg";
import LogoM from "../assets/logo-in3-mobil.svg";
const Header = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -123,7 +123,7 @@ const Header = () => {
>
<img
src={Logo}
alt="centre de formation numérique"
alt="Bureau d'études Le Mans"
style={{
width: "50px",
height: "50px",
@@ -150,7 +150,7 @@ const Header = () => {
...commonButtonStyles,
}}
>
Activités
Services
</Button>
<Menu
id="services-menu"
@@ -167,45 +167,24 @@ const Header = () => {
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1 }} />
Création de site Web
Prestation maitrise oeuvre
</MenuItem>
<MenuItem
component={Link}
to="/services/formations-web"
to="/services/structure-beton-charpente-metallique-bois"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1 }} />
Formations web
Structure béton | charpente métallique / bois
</MenuItem>
<MenuItem
component={Link}
to="/services/electricite"
to="/services/formation-video"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1}} />
Graphisme
Électricité
</MenuItem>
<MenuItem
component={Link}
to="/services/service-securite-incendie"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1}} />
Podcast
</MenuItem>
<MenuItem
component={Link}
to="/services/expertises-tce"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1}} />
Formations
</MenuItem>
</Menu>
<Button
@@ -219,9 +198,9 @@ const Header = () => {
...commonButtonStyles,
}}
>
Actualités
Réalisations
</Button>
{/* <Button
<Button
component={Link}
to="/about"
color="inherit"
@@ -233,10 +212,10 @@ const Header = () => {
}}
>
À Propos
</Button> */}
</Button>
<Button
component={Link}
to="/nosFormations"
to="/bureauEtude"
color="inherit"
sx={{
fontWeight: location.pathname === "/bureauEtude" ? "bold" : "normal",
@@ -245,7 +224,7 @@ const Header = () => {
...commonButtonStyles,
}}
>
Formations
Bureau d'étude
</Button>
<Button
component={Link}
@@ -369,55 +348,28 @@ const Header = () => {
selected={location.pathname.includes("/services/prestation-maitrise-oeuvre")}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Création site web" />
<ListItemText primary="Prestation maitrise oeuvre" />
</ListItem>
<ListItem
button
component={Link}
to="/services/formations-web"
selected={location.pathname.includes("/services/formations-web")}
to="/services/formation-ia"
selected={location.pathname.includes("/services/formation-ia")}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Formations web..." />
<ListItemText primary="Formation IA" />
</ListItem>
<ListItem
button
component={Link}
to="/services/electricite"
to="/services/formation-video"
selected={location.pathname.includes(
"/services/electricite"
"/services/formation-video"
)}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Graphisme..." />
<ListItemText primary="Formation Video" />
</ListItem>
<ListItem
button
component={Link}
to="/services/service-securite-incendie"
selected={location.pathname.includes(
"/services/service-securite-incendie"
)}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Podcast..." />
</ListItem>
<ListItem
button
component={Link}
to="/nosFormations"
selected={location.pathname.includes(
"/nosFormations"
)}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Formations..." />
</ListItem>
<ListItem
button
component={Link}
@@ -425,10 +377,9 @@ const Header = () => {
selected={location.pathname === "/posts"}
>
<ArticleIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Actualités..." />
<ListItemText primary="Réalisations" />
</ListItem>
{/* <ListItem
<ListItem
button
component={Link}
to="/about"
@@ -436,8 +387,7 @@ const Header = () => {
>
<InfoIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="À propos" />
</ListItem> */}
</ListItem>
<ListItem
button
component={Link}
@@ -447,7 +397,6 @@ const Header = () => {
<ContactMailIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Contact" />
</ListItem>
</List>
</Box>
</Drawer>
+1 -3
View File
@@ -1,7 +1,6 @@
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor }) => {
// Priorité pour appliquer le fond : Image > Dégradé > Aucun fond
const backgroundStyle = backgroundImage
@@ -61,10 +60,9 @@ const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor
fontWeight: 'bold',
padding: { xs: '10px 20px', md: '15px 30px' }, // Responsive
fontSize: { xs: '0.8rem', md: '1rem' },
backgroundColor: '#315397',
}}
>
Nos Formations
Explorer les Articles
</Button>
</Box>
);
@@ -1,35 +0,0 @@
import React from "react";
const CLOUD_NAME = "dh5qgexjo";
const UPLOAD_PRESET = "preset_articles"; // crée-le dans Cloudinary si ce nest pas encore fait
const FOLDER = "articles-octopus";
const ImageUploaderCloudinary = ({ onUploadSuccess }) => {
const handleUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append("upload_preset", UPLOAD_PRESET);
formData.append("folder", FOLDER);
const res = await fetch(`https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`, {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.secure_url) {
const transformedUrl = data.secure_url.replace("/upload/", "/upload/f_webp,w_800/");
onUploadSuccess(transformedUrl); // ← Ici on t'envoie lURL optimisée
} else {
alert("❌ Erreur lors de l'upload");
}
};
return <input type="file" accept="image/*" onChange={handleUpload} />;
};
export default ImageUploaderCloudinary;
+2 -2
View File
@@ -26,7 +26,7 @@ const About = () => {
{
icon: <BusinessIcon />,
title: "Bureau d’études et Maitrise d’œuvre",
link: "/services/prestation-maitrise-oeuvre",
link: "/services/maitrise-oeuvre",
},
{
icon: <BuildIcon />,
@@ -140,7 +140,7 @@ const About = () => {
mb: 4, mt:5
}}
>
La société IN3 !!!
La société IN3
</Typography>
<Typography
variant="body2"
+73 -76
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import {
Box,
Typography,
@@ -15,31 +15,26 @@ const BureauEtudes = () => {
useEffect(() => {
const fetchPageData = async () => {
try {
const response = await api.get("wp/v2/pages/272?_fields=acf");
console.log("expertises_specifiques:", response.data.acf.expertises_specifiques);
const response = await api.get("wp/v2/pages/272?_fields=acf"); // Remplace XXX par l'ID WordPress de la page
setPageData(response.data.acf);
} catch (error) {
console.error("Erreur lors de la récupération des données ACF :", error);
console.error(
"Erreur lors de la récupération des données ACF :",
error
);
}
};
fetchPageData();
}, []);
const expertises = useMemo(() => {
if (!pageData?.expertises_specifiques) return [];
return Array.isArray(pageData.expertises_specifiques)
? pageData.expertises_specifiques
: Object.values(pageData.expertises_specifiques);
}, [pageData]);
if (!pageData) {
return <Typography>Chargement...</Typography>;
}
return (
<Box>
{/* Section Héros */}
<Box sx={{}}>
{/* Section Héros avec Image de Fond */}
<Box
sx={{
backgroundImage: "url('https://picsum.photos/1920/600.webp')",
@@ -55,33 +50,29 @@ const BureauEtudes = () => {
px: 4,
}}
>
{/* Présentation Générale */}
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
<Typography
variant="h1"
sx={{
fontSize: { xs: "3rem", md: "3rem", lg: "3.5rem" },
fontWeight: "bold",
mb: 2,
}}
>
Nos Formations
</Typography>
<Typography
variant="body2"
sx={{ maxWidth: "900px", mx: "auto" }}
dangerouslySetInnerHTML={{ __html: pageData.introduction }}
/>
</Box>
</Box>
{/* Présentation Générale */}
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
<Typography variant="h1" sx={{
fontSize: { xs: "3rem", md: "3rem", lg: "3.5rem" }, // Responsive
fontWeight: "bold",
mb: 2,
}}>
Notre Bureau d'Études
</Typography>
<Typography variant="body2" sx={{ maxWidth: "900px", mx: "auto" }}>
{pageData.introduction}
</Typography>
</Box>
</Box>
{/* Domaines d'Intervention */}
<Box sx={{ mt: 6, px: 4 }}>
<Typography
variant="h2"
sx={{ fontSize: "2.5rem", fontWeight: "bold", textAlign: "center", mb: 3 }}
>
Nos formations sur mesure
<Typography variant="h2" sx={{
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
fontWeight: "bold",
textAlign: "center",
mb: 3 }}>
Nos Domaines d'Intervention
</Typography>
<Grid container spacing={4} justifyContent="center">
{pageData.liste_des_competences?.map((competence, index) => (
@@ -98,15 +89,16 @@ const BureauEtudes = () => {
}}
>
<CardContent>
<Typography
variant="h3"
sx={{ fontWeight: "bold", mb: 1, fontSize: "1.8rem" }}
>
<Typography variant="h3" sx={{
fontWeight: "bold",
mb: 1,
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
}}>
{competence.comp_titre}
</Typography>
<Typography variant="body2" sx={{ textAlign: "center" }}>
{competence.comp_description}
</Typography>
<Typography variant="body2" sx={{
textAlign:"center"
}}>{competence.comp_description}</Typography>
</CardContent>
</Card>
</Grid>
@@ -116,7 +108,11 @@ const BureauEtudes = () => {
{/* Compétences Spécifiques */}
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
<Typography variant="h2" sx={{
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
fontWeight: "bold",
mb: 3
}}>
Nos Compétences
</Typography>
<Typography variant="body1" sx={{ maxWidth: "900px", mx: "auto" }}>
@@ -135,40 +131,41 @@ const BureauEtudes = () => {
color: "white",
}}
>
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
<Typography variant="h2" sx={{
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
fontWeight: "bold",
mb: 3
}}>
Engagement et Accompagnement
</Typography>
<Grid container spacing={4} justifyContent="center">
{expertises.length > 0 ? (
expertises.map((expertise, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card
sx={{
textAlign: "center",
padding: 3,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(10px)",
transition: "transform 0.3s, box-shadow 0.3s",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
},
}}
>
<CardContent>
<Typography variant="h3" sx={{ fontSize: "1.8rem", fontWeight: "bold", mb: 1 }}>
{expertise.expertise_nom}
</Typography>
<Typography variant="body2">
{expertise.expertise_details}
</Typography>
</CardContent>
</Card>
</Grid>
))
) : (
<Typography>Aucune expertise spécifique à afficher.</Typography>
)}
{pageData.expertises_specifiques?.map((expertise, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card
sx={{
textAlign: "center",
padding: 3,
backgroundColor: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(10px)",
transition: "transform 0.3s, box-shadow 0.3s",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
},
}}
>
<CardContent>
<Typography variant="h3" sx={{
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
fontWeight: "bold",
mb: 1 }}>
{expertise.expertise_nom}
</Typography>
<Typography variant="body2">{expertise.expertise_details}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
+17 -48
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, Suspense } from "react";
import React, { useState, useEffect, lazy, Suspense } from "react";
import {
Box,
TextField,
@@ -19,15 +19,9 @@ import {
import SEO from "../SEO";
import api from "../../api";
import Faq from "../Faq";
const FAQ = lazy(() => import('../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."
@@ -41,11 +35,7 @@ const Contact = () => {
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [alertState, setAlertState] = useState({
open: false,
severity: "success",
message: "",
});
const [successMessage, setSuccessMessage] = useState(false);
const [openLightbox, setOpenLightbox] = useState(false);
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
@@ -106,7 +96,7 @@ const Contact = () => {
try {
const response = await fetch(
CONTACT_API_URL,
"https://votre-site.com/wp-json/custom/v1/contact",
{
method: "POST",
headers: {
@@ -117,11 +107,7 @@ const Contact = () => {
);
if (response.ok) {
setAlertState({
open: true,
severity: "success",
message: "Merci ! Votre message a bien été envoyé.",
});
setSuccessMessage(true);
setFormData({
name: "",
email: "",
@@ -134,13 +120,7 @@ const Contact = () => {
throw new Error(errorData.message || "Une erreur est survenue.");
}
} catch (error) {
setAlertState({
open: true,
severity: "error",
message:
error.message ||
"Impossible d'envoyer votre message. Veuillez réessayer.",
});
alert(error.message);
} finally {
setLoading(false);
}
@@ -155,13 +135,6 @@ const Contact = () => {
setOpenLightbox(false);
};
const handleCloseAlert = (_, reason) => {
if (reason === "clickaway") {
return;
}
setAlertState((prev) => ({ ...prev, open: false }));
};
return (
<Box>
{/* SEO */}
@@ -243,14 +216,14 @@ const Contact = () => {
</Typography>
<Typography variant="body1">
<strong>Adresse :</strong>Route du Perrier, Saint HIlaire de Riez,
<strong>Adresse :</strong>67 Bd Winston Churchill, Le Mans,
France
</Typography>
<Typography variant="body1">
<strong>Téléphone :</strong> 06.00.00.00.00
<strong>Téléphone :</strong> 02.43.85.09.01
</Typography>
<Typography variant="body1">
<strong>Email :</strong> contact@octopusdesign.fr
<strong>Email :</strong> contact@be-in3.com
</Typography>
</Box>
@@ -305,7 +278,7 @@ const Contact = () => {
>
<CardContent>
<Typography
variant="h3"
variant="h2"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
fontWeight: "bold",
@@ -359,7 +332,7 @@ const Contact = () => {
}}
>
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2732.7778475259584!2d-1.9653890876882651!3d46.769275171004885!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x4804ffa63669919f%3A0x50e5fd73af1e7d87!2sOctopus%20Design!5e0!3m2!1sfr!2sfr!4v1753993974889!5m2!1sfr!2sfr"
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2670.1862696285393!2d0.22040227623964043!3d47.990787261369235!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47e28ec5f984044d%3A0xa3a21789f4a4eaa6!2sIN3%20Int%C3%A9grale%20Ing%C3%A9nierie%20Internationale%20Bureau%20D&#39;%C3%A9tudes%20Ing%C3%A9nieries%20Ma%C3%AEtrise%20d&#39;oeuvre!5e0!3m2!1sfr!2sfr!4v1736328111099!5m2!1sfr!2sfr%22%20width=%22600%22%20height=%22450%22%20style=%22border:0;%22%20allowfullscreen=%22%22%20loading=%22lazy%22%20referrerpolicy=%22no-referrer-when-downgrade%22"
width="100%"
height="400"
title="Carte interactive"
@@ -383,9 +356,10 @@ const Contact = () => {
<Typography
variant="h2"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, // Responsive
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" }, // Responsive
fontWeight: "bold",
mb: 2,
fontSize: { xs: "1.5rem", md: "2rem" },
}}
>
Besoin d'une assistance immédiate?
@@ -517,17 +491,12 @@ const Contact = () => {
</form>
<Snackbar
open={alertState.open}
open={successMessage}
autoHideDuration={6000}
onClose={handleCloseAlert}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
onClose={() => setSuccessMessage(false)}
>
<Alert
onClose={handleCloseAlert}
severity={alertState.severity}
sx={{ width: "100%" }}
>
{alertState.message}
<Alert onClose={() => setSuccessMessage(false)} severity="success">
Message envoyé avec succès!
</Alert>
</Snackbar>
</Box>
+44 -462
View File
@@ -1,472 +1,54 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { uploadImageFromUrl, createPost } from "../../wordpress";
import { getToken } from "../../auth";
import {
Box,
Typography,
TextField,
Button,
Grid,
Card,
CardContent,
Stack,
Chip,
Divider,
} from "@mui/material";
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
import RichTextEditor from "../common/RichTextEditor";
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),
});
import { useState } from "react";
import { uploadImage, createPost } from "../../wordpress";
function CreatePost() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageUrl, setImageUrl] = useState(null);
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [file, setFile] = useState(null);
// Vérification de l'authentification
useEffect(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
const canPublish =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
try {
console.log("📢 Tentative d'upload de l'image...");
const imageId = await uploadImage(file);
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 || error
);
alert("❌ Erreur lors de la création du post.");
} finally {
setSubmitting(false);
}
};
console.log("📢 Création du post avec l'image mise en avant...");
await createPost(title, content, imageId);
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
alert("✅ Post créé avec succès !");
} catch (error) {
alert("❌ Erreur lors de la création du post.");
}
};
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="contained"
onClick={() => navigate("/admin/gestion-articles")}
sx={composeButtonSx("ghost")}
>
Retour aux articles
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Publication Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Rédiger un nouvel article
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Structurez votre contenu, ajoutez une image mise en avant et
publiez directement sur le blog WordPress Octopus.
</Typography>
</Stack>
</Stack>
<Grid container spacing={3.5}>
<Grid item xs={12} lg={7}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardContent sx={{ p: 0 }}>
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: "wrap" }}
>
<Chip
label="Brouillon"
sx={{
bgcolor: "rgba(49,83,151,0.12)",
color: "#315397",
fontWeight: 600,
}}
/>
<Chip
label={
canPublish
? "Prêt pour publication"
: "Complétez les champs requis"
}
color={canPublish ? "success" : "warning"}
sx={{ fontWeight: 600 }}
/>
</Stack>
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
label="Titre de l'article"
fullWidth
variant="outlined"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<RichTextEditor
value={content}
onChange={setContent}
minHeight={280}
/>
</Box>
<Stack spacing={2.5}>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Importer une image
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ajoutez un visuel optimisé pour les réseaux sociaux
(format 1600x900 recommandé).
</Typography>
<Box sx={{ mt: 2 }}>
<ImageUploaderCloudinary
onUploadSuccess={(url) => setImageUrl(url)}
/>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }} />
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Sélectionner depuis la médiathèque
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 2 }}
>
Retrouver un visuel déjà téléversé dans Cloudinary.
</Typography>
<CloudinaryGallerySelector
onSelect={(url) => setImageUrl(url)}
/>
</Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ pt: 1 }}
>
<Button
type="submit"
variant="contained"
startIcon={<Publish />}
disabled={!canPublish || submitting}
sx={{ ...composeButtonSx("primary"), minWidth: 200 }}
>
{submitting ? "Publication..." : "Publier l'article"}
</Button>
<Button
variant="contained"
onClick={() => {
setTitle("");
setContent("");
setImageUrl(null);
}}
sx={composeButtonSx("outline")}
>
Réinitialiser
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
bgcolor: "rgba(123,192,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
}}
>
<ImageIcon />
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d", mb: 1 }}
>
Conseils de rédaction
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)" }}
>
Introduisez le sujet dans les 250 premiers caractères pour
optimiser le SEO.<br />
Utilisez des sous-titres (<code>&lt;h2&gt;</code>) pour
clarifier la structure.<br />
Ajoutez des appels à laction vers vos formations Octopus.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel sera utilisé comme image de couverture sur la liste
des articles et les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
);
return (
<div>
<h2>Créer un post</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Titre du post"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="Contenu du post"
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files[0])}
required
/>
<button type="submit">Publier</button>
</form>
</div>
);
}
export default CreatePost;
+55 -497
View File
@@ -1,521 +1,79 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getToken, logout } from "../../auth"; // Importation de la fonction logout
import {
Box,
Typography,
Grid,
Card,
CardContent,
Button,
Stack,
Chip,
Divider,
IconButton,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import ArticleIcon from "@mui/icons-material/Article";
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),
});
import DashboardIcon from "@mui/icons-material/Dashboard";
function Dashboard() {
const navigate = useNavigate();
const token = getToken();
useEffect(() => {
if (!token) {
navigate("/admin/login"); // Redirige vers la page de connexion si non authentifié
}
}, [token, navigate]);
const handleLogout = () => {
logout(); // Déconnecte l'utilisateur
navigate("/admin/login"); // Redirige vers la page de connexion
};
const NAV_ITEMS = [
{
title: "Page daccueil",
description: "Mettez à jour les blocs héros, témoignages et sections clés.",
navigateTo: "/admin/Gestion-Page-Accueil",
icon: <HomeIcon fontSize="inherit" />,
accent: "rgba(123,192,255,0.35)",
},
{
title: "Tools studio",
description: "Centralisez vos outils internes et automatisez les tâches.",
navigateTo: "/admin/tools",
icon: <BuildIcon fontSize="inherit" />,
accent: "rgba(255,214,150,0.32)",
},
{
title: "Articles",
description: "Rédigez, programmez et optimisez les publications du blog.",
navigateTo: "/admin/gestion-articles",
icon: <ArticleIcon fontSize="inherit" />,
accent: "rgba(255,172,236,0.28)",
},
{
title: "Pages services",
description: "Actualisez les contenus ACF pour chaque offre Octopus.",
navigateTo: "/admin/pages-acf",
icon: <SettingsSuggestIcon fontSize="inherit" />,
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: <InsightsIcon fontSize="inherit" />,
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: <SchoolIcon fontSize="inherit" />,
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 (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
backgroundImage: "url('https://source.unsplash.com/1600x900/?technology,office')",
backgroundSize: "cover",
backgroundPosition: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "20px",
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Tableau de bord centralisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 540,
}}
>
Accédez à lensemble des outils de gestion : contenus éditoriaux,
pages services, ressources pédagogiques et automatisations.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<LogoutIcon />}
onClick={handleLogout}
sx={composeButtonSx("ghost")}
>
Déconnexion
</Button>
</Stack>
<Box
className="glass-panel hero-panel"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
mb: 4,
position: "relative",
overflow: "hidden",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={{ xs: 3, md: 4 }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
>
<Box sx={{ flex: 1 }}>
<Typography
variant="h5"
sx={{
color: "#0b1a3d",
fontWeight: 700,
mb: 1,
}}
>
Priorités du jour
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.7)", lineHeight: 1.6 }}
>
Suivez la progression des mises en ligne, synchronisez Moodle
et gardez un œil sur vos contenus essentiels.
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: 2, flexWrap: "wrap" }}
>
<Chip
icon={<AutoStoriesIcon fontSize="small" />}
label="Contenus pédagogiques à jour"
sx={{
bgcolor: "rgba(123,192,255,0.16)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
icon={<PublicIcon fontSize="small" />}
label="Octopusdesign.fr en production"
sx={{
bgcolor: "rgba(255,214,150,0.18)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Stack>
</Box>
<Divider
orientation="vertical"
flexItem
sx={{
display: { xs: "none", md: "block" },
borderColor: "rgba(11, 26, 61, 0.08)",
}}
/>
<Box
sx={{
flexBasis: { xs: "100%", md: "40%" },
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Typography
variant="subtitle2"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 700 }}
>
Actions rapides
</Typography>
<Stack spacing={1.2}>
{QUICK_ACTIONS.map((action) => (
<Button
key={action.label}
variant="contained"
onClick={action.onClick}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
...composeButtonSx("subtle", "large"),
justifyContent: "space-between",
width: "100%",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{action.label}
</Typography>
<Typography
variant="caption"
sx={{ color: "rgba(49,83,151,0.7)", fontWeight: 500 }}
>
{action.hint}
</Typography>
</Box>
</Button>
))}
</Stack>
</Box>
</Stack>
</Box>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
{KPI_CARDS.map((card) => (
<Grid item xs={12} sm={4} key={card.label}>
<Card
className="glass-subcard"
sx={{
borderRadius: 3,
p: 2.5,
boxShadow: "0 28px 34px -30px rgba(12, 29, 74, 0.52)",
}}
>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
{card.label.toUpperCase()}
</Typography>
<Typography
variant="h4"
sx={{
color: "#0b1a3d",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
{card.value}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{card.delta}
</Typography>
</Card>
</Grid>
))}
</Grid>
<Typography
variant="h6"
sx={{ color: "#ffffff", fontWeight: 700, mb: 2 }}
>
Espaces de gestion
<Box
sx={{
width: "90%",
maxWidth: "900px",
padding: 4,
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 3,
boxShadow: 6,
textAlign: "center",
}}
>
{/* En-tête */}
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
<DashboardIcon sx={{ fontSize: 40, color: "#0e467f", mr: 1 }} />
Tableau de Bord
</Typography>
<Grid container spacing={2.5}>
{NAV_ITEMS.map((item) => (
<Grid item xs={12} md={6} key={item.title}>
<Card
onClick={() => 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)",
},
}}
>
<CardContent
sx={{
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.75)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
boxShadow: "0 16px 26px -20px rgba(11, 26, 61, 0.5)",
fontSize: 28,
}}
>
{item.icon}
</Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
{item.title}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
>
{item.description}
</Typography>
<Button
variant="contained"
onClick={() => navigate(item.navigateTo)}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
...composeButtonSx("primary", "small"),
alignSelf: "flex-start",
}}
>
Ouvrir
</Button>
</CardContent>
</Card>
</Grid>
))}
{/* Cartes du Dashboard */}
<Grid container spacing={3} justifyContent="center">
<Grid item xs={12} sm={6} md={4}>
<Card
onClick={() => navigate("/posts")}
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",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<ArticleIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Gérer les Articles
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
</Box>
File diff suppressed because it is too large Load Diff
@@ -1,566 +0,0 @@
// Importations nécessaires
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
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é
useEffect(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
// Récupérer les articles et leurs images associées
useEffect(() => {
const fetchPosts = async () => {
try {
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(
postsData.map(async (post) => {
if (post.featured_media) {
try {
const mediaResponse = await api.get(`wp/v2/media/${post.featured_media}`);
return { ...post, image: mediaResponse.data.source_url, imageId: post.featured_media };
} catch (error) {
console.error(`❌ Erreur chargement image article ${post.id}:`, error);
return { ...post, image: null, imageId: null };
}
}
return { ...post, image: null, imageId: null };
})
);
setPosts(postsWithImages);
} catch (error) {
console.error("❌ Erreur chargement des articles :", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
// Supprimer un article et son image associée
const stripHtml = (value = "") =>
value.replace(/(<([^>]+)>)/gi, "").replace(/&nbsp;/gi, " ").trim();
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 (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="contained"
onClick={handleBack}
sx={{ ...composeButtonSx("ghost") }}
>
Retour
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Contenu éditorial
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Gestion des articles
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Consultez les publications WordPress, ajustez vos contenus et
maintenez un flux éditorial cohérent pour Octopus.
</Typography>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary") }}
>
Nouvel article
</Button>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Total articles
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.total}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{filteredPosts.length} résultat(s) après filtre
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Articles illustrés
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.withImage}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.withoutImage} sans visuel associé
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Dernière publication
</Typography>
<Typography
variant="subtitle1"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{stats.latestLabel}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.latestDate}
</Typography>
</Card>
</Grid>
</Grid>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
mb: 4,
boxShadow: "0 24px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
Rechercher un article
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Filtrez par titre ou résumé pour retrouver un contenu en quelques
secondes.
</Typography>
</Box>
<TextField
label="Rechercher un article..."
variant="outlined"
fullWidth
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
sx={{
maxWidth: 380,
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.92)",
},
}}
/>
</Stack>
</Card>
{loading ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<CircularProgress color="inherit" />
</Box>
) : filteredPosts.length === 0 ? (
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: 4,
textAlign: "center",
color: "#0b1a3d",
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
Aucun article trouvé
</Typography>
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Ajustez votre recherche ou créez un nouvel article pour alimenter le blog.
</Typography>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary"), mt: 3 }}
>
Créer un article
</Button>
</Card>
) : (
<Grid container spacing={2.5}>
{filteredPosts.map((post) => (
<Grid item xs={12} md={6} key={post.id}>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
boxShadow: "0 32px 40px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardMedia
component="div"
image={
post.image ||
"https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&w=1200&q=80"
}
sx={{
height: 200,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<CardContent
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
label={`ID ${post.id}`}
sx={{
bgcolor: "rgba(49,83,151,0.12)",
color: "#315397",
fontWeight: 600,
}}
/>
<Tooltip title={stats.latestLabel === post.title.rendered ? "Dernière publication" : ""}>
<Chip
size="small"
label={formatDate(post.date)}
sx={{
bgcolor: "rgba(255,255,255,0.72)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Tooltip>
</Stack>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{post.title.rendered}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
>
{previewExcerpt(post.excerpt?.rendered)}
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: "auto" }}
>
<Button
startIcon={<Edit />}
variant="contained"
onClick={() => handleEdit(post.id)}
sx={{ ...composeButtonSx("outline", "small") }}
>
Modifier
</Button>
<Button
startIcon={<Delete />}
variant="contained"
onClick={() => handleDeletePost(post.id, post.imageId)}
sx={{ ...composeButtonSx("danger", "small") }}
>
Supprimer
</Button>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
</Box>
);
};
export default GestionArticles;
File diff suppressed because it is too large Load Diff
+28 -81
View File
@@ -5,17 +5,9 @@ import api from "../../api";
import ModernSpinner from "../SpinnerModerne";
import Expertises from "../Expertises";
import ConstructSection from "../ConstructSection";
import {
Box,
Grid,
Typography,
Button,
Card,
CardContent,
} from "@mui/material";
import Logo from "../../assets/centre-formation-logo.avif";
import { Box, Grid, Typography, Button, Card, CardContent } from "@mui/material";
import Logo from "../../assets/logo-in3-mobil.svg";
import Testi from "../Testi";
import "../../assets/style.css";
const Home = () => {
const [pageData, setPageData] = useState(null);
@@ -29,23 +21,14 @@ const Home = () => {
setIsLoading(true);
setError(null);
// 🔥 Ajout d'un timestamp pour éviter la mise en cache
const response = await api.get(
`wp/v2/pages/13?_fields=acf,link,rank_math_title,rank_math_description&_=${new Date().getTime()}`
);
const response = await api.get("wp/v2/pages/13?_fields=acf,rank_math_title,rank_math_description");
const pageContent = response.data;
setPageData(pageContent);
// Récupération de l'image Hero (Cloudinary ou WordPress)
if (
pageContent.acf?.img_hero_url &&
pageContent.acf.img_hero_url.startsWith("http")
) {
setHeroImage(pageContent.acf.img_hero_url);
} else if (pageContent.acf?.img_hero) {
const mediaResponse = await api.get(
`wp/v2/media/${pageContent.acf.img_hero}?_=${new Date().getTime()}`
);
// Vérification et récupération de l'image Hero depuis l'API media de WordPress
const heroImageId = pageContent.acf?.img_hero;
if (heroImageId) {
const mediaResponse = await api.get(`wp/v2/media/${heroImageId}`);
setHeroImage(mediaResponse.data.source_url);
} else {
setHeroImage(null);
@@ -79,26 +62,19 @@ const Home = () => {
);
}
const { acf, rank_math_title, rank_math_description, link } = pageData || {};
const fallbackUrl =
typeof window !== "undefined" ? window.location.href : "";
const pageUrl = link || fallbackUrl;
const { acf, rank_math_title, rank_math_description } = pageData || {};
return (
<div>
{/* SEO */}
<SEO
pageUrl={pageUrl}
defaultTitle={rank_math_title || "centre de formation "}
defaultDescription={
rank_math_description ||
"formation et reconversion professionnelle dans les métiers du numérique"
}
defaultCanonicalUrl={acf?.canonical_url || pageUrl}
defaultOgImage={
heroImage ||
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
}
postId={13} // ID WordPress valide
defaultTitle={rank_math_title || "Accueil - Mon Site"}
defaultDescription={rank_math_description || "Bienvenue sur notre site !"}
defaultCanonicalUrl={acf?.canonical_url || window.location.href}
defaultOgImage={heroImage || "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"}
/>
{/* Section Héros */}
@@ -113,22 +89,18 @@ const Home = () => {
{/* Section Construisons */}
<ConstructSection
constructTitle={
acf?.gdc_construct?.construct_title || "Construisons vos projets"
}
constructTitle={acf?.gdc_construct?.construct_title || "Construisons vos projets"}
constructSubtitle={acf?.gdc_construct?.construct_subtitle || "ensemble"}
constructText={acf?.gdc_construct?.construct_text || "Texte par défaut"}
constructSector={acf?.gdc_construct?.construct_sector || "Secteurs"}
constructNoteText={acf?.gdc_construct?.construct_note || "Note société"}
missionTitle={acf?.mission_title || "Nos missions principales"}
missionItems={acf?.mission || []}
missionItems={acf?.mission_items || []}
/>
{/* Section Expertises */}
<Expertises
expertiseTitle={
acf?.titre_expertise.titre_expertise || "Pourquoi Nous Choisir ?"
}
expertiseTitle={acf?.titre_expertise || "Pourquoi Nous Choisir ?"}
expertise1Title={acf?.gdc_expert?.titre_expertise_1 || "Expertise 1"}
expertise1Text={acf?.gdc_expert?.text_expertise_1 || "Text Expertise 1"}
expertise2Title={acf?.gdc_expert?.titre_expertise_2 || "Expertise 2"}
@@ -137,27 +109,16 @@ const Home = () => {
expertise3Text={acf?.gdc_expert?.text_expertise_3 || "Text Expertise 3"}
/>
{/* Section Témoignages */}
<Typography
variant="h2"
sx={{
fontWeight: "bold",
mb: 2,
textAlign: "center",
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
}}
>
Ce que disent nos clients
</Typography>
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, textAlign: "center", fontSize: { xs: "2rem", md: "2rem", lg: "3rem" } }}>
Ce que disent nos clients
</Typography>
<Testi />
{/* Section Action */}
<Box sx={{ py: 6, px: 4, backgroundColor: "#0e467f", color: "#fff" }}>
<Typography
variant="h2"
align="center"
gutterBottom
sx={{ fontWeight: "bold", mb: 4 }}
>
<Typography variant="h2" align="center" gutterBottom sx={{ fontWeight: "bold", mb: 4 }}>
Prêt à Commencer ?
</Typography>
<Box textAlign="center">
@@ -180,15 +141,7 @@ const Home = () => {
{/* Section Témoignages */}
<Box sx={{ py: 6, px: 4 }}>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
mb: 2,
textAlign: "center",
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
}}
>
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, textAlign: "center", fontSize: { xs: "2rem", md: "2rem", lg: "3rem" } }}>
Nos partenaires
</Typography>
<Grid container spacing={4}>
@@ -198,11 +151,8 @@ const Home = () => {
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
Un service exceptionnel, une équipe formidable
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
>
- zertides
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
- Client 1
</Typography>
</CardContent>
</Card>
@@ -213,10 +163,7 @@ const Home = () => {
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
Des résultats impressionnants pour notre projet.
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
>
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
- Client 2
</Typography>
</CardContent>
+72 -228
View File
@@ -1,269 +1,113 @@
import { useState } from "react";
import { loginWithAppPassword, getToken, logout } from "../../auth";
import { useNavigate } from "react-router-dom"; // Import de useNavigate pour la redirection
import {
Box,
Button,
Grid,
Card,
CardContent,
TextField,
Typography,
Stack,
CircularProgress,
Chip,
Container,
Avatar,
} 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);
// alert("Connexion réussie !");
} else {
setError("Échec de la connexion. Vérifiez vos identifiants.");
alert("Échec de la connexion !");
}
setLoading(false);
};
const handleLogout = () => {
logout();
setToken(null);
// alert("Déconnexion réussie !");
};
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 (
<Box
className="glass-dashboard"
sx={{
backgroundImage: "url('https://source.unsplash.com/random/1600x900?technology')",
backgroundSize: "cover",
backgroundPosition: "center",
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 8, md: 12 },
pb: { xs: 8, md: 10 },
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Container maxWidth="xs">
<Card
sx={{
padding: 4,
boxShadow: 6,
borderRadius: 3,
textAlign: "center",
backdropFilter: "blur(10px)",
backgroundColor: "rgba(255, 255, 255, 0.8)",
}}
>
<CardContent>
<Avatar sx={{ margin: "auto", backgroundColor: "#0e467f" }}>
<LockOutlinedIcon />
</Avatar>
<Typography variant="h5" sx={{ fontWeight: "bold", mt: 2 }}>
{token ? "Bienvenue !" : "Connexion"}
</Typography>
<Grid
container
spacing={4}
alignItems="center"
justifyContent="center"
sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}
>
<Grid item xs={12} md={6}>
<Stack spacing={2.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
lineHeight: 1.1,
}}
>
Accédez à votre espace sécurisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
lineHeight: 1.6,
maxWidth: 420,
}}
>
Connectez-vous avec vos identifiants dapplication pour gérer les
contenus Octopus : articles, pages services, formations et plus
encore.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Authentification sécurisée"
sx={{
bgcolor: "rgba(255,255,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Accès réservé Octopus Team"
sx={{
bgcolor: "rgba(123,192,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} md={6}>
<Box
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
boxShadow: "0 32px 46px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={3} alignItems="center">
<Box
sx={{
width: 72,
height: 72,
borderRadius: "50%",
bgcolor: "rgba(49,83,151,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
boxShadow: "0 18px 28px -20px rgba(12, 29, 74, 0.45)",
}}
{token ? (
<Button
variant="contained"
color="error"
onClick={handleLogout}
sx={{ mt: 3, width: "100%" }}
>
<LockOutlinedIcon fontSize="large" />
</Box>
<Typography
variant="h5"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
{token ? "Bienvenue dans lespace Octopus" : "Connexion"}
</Typography>
{token ? (
<Stack spacing={1.5} sx={{ width: "100%" }}>
<Button
variant="contained"
onClick={() => navigate("/admin/dashboard")}
sx={{ ...composeButtonSx("primary"), width: "100%" }}
>
Accéder au tableau de bord
</Button>
<Button
variant="contained"
onClick={handleLogout}
sx={{ ...composeButtonSx("outline"), width: "100%" }}
>
Déconnexion
</Button>
</Stack>
) : (
<Box
component="form"
onSubmit={handleLogin}
sx={{ width: "100%" }}
Déconnexion
</Button>
) : (
<form onSubmit={handleLogin}>
<TextField
label="Nom d'utilisateur"
fullWidth
margin="normal"
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
label="Mot de passe d'application"
fullWidth
margin="normal"
variant="outlined"
type="password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
required
/>
<Button
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3, width: "100%" }}
>
<Stack spacing={2}>
<TextField
label="Nom dutilisateur"
fullWidth
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.95)",
},
}}
/>
<TextField
label="Mot de passe dapplication"
fullWidth
variant="outlined"
type="password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
required
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.95)",
},
}}
/>
{error ? (
<Typography color="error" variant="body2">
{error}
</Typography>
) : null}
<Button
type="submit"
variant="contained"
disabled={loading}
sx={{ ...composeButtonSx("primary"), width: "100%" }}
>
{loading ? (
<CircularProgress size={20} sx={{ color: "#ffffff" }} />
) : (
"Se connecter"
)}
</Button>
</Stack>
</Box>
)}
</Stack>
</Box>
</Grid>
</Grid>
Se connecter
</Button>
</form>
)}
</CardContent>
</Card>
</Container>
</Box>
);
}
+12 -28
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { Box, Typography, Grid, Card, CardContent, CardMedia, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
import api from "../../api";
@@ -10,7 +10,7 @@ const Posts = () => {
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await api.get("wp/v2/posts?_fields=id,slug,title,excerpt,content,featured_media");
const response = await api.get("wp/v2/posts?_fields=id,title,excerpt,featured_media");
const postsData = response.data;
// Récupérer les images en vedette
@@ -38,32 +38,16 @@ const Posts = () => {
fetchPosts();
}, []);
// Fonction pour nettoyer le HTML et récupérer un texte brut
const stripHtmlTags = (html) => {
return html.replace(/(<([^>]+)>)/gi, ""); // Supprime toutes les balises HTML
// Fonction pour tronquer le texte à 70 caractères
const truncateText = (text, maxLength) => {
const strippedText = text.replace(/(<([^>]+)>)/gi, ""); // Suppression des balises HTML
return strippedText.length > maxLength ? `${strippedText.substring(0, maxLength)}...` : strippedText;
};
const decodeHtml = (html) => {
const txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value;
};
// Fonction pour tronquer un texte à 100 caractères max
const truncateText = (text, maxLength) => {
const parser = new DOMParser();
const decoded = parser.parseFromString(text, "text/html").body.textContent || "";
const cleanText = decoded.replace(/&nbsp;/g, " "); // Optionnel si besoin
return cleanText.length > maxLength ? `${cleanText.substring(0, maxLength)}...` : cleanText;
};
return (
<Box sx={{ padding: "40px 20px" }}>
<Typography variant="h1" sx={{ fontWeight: "bold", fontSize: "3em", color:"#315397", textAlign: "center", mb: 4, mt:5 }}>
L'actualité du numérique
<Typography variant="h2" sx={{ fontWeight: "bold", textAlign: "center", mb: 4 }}>
Nos Articles
</Typography>
<Grid container spacing={4} justifyContent="center">
@@ -72,19 +56,19 @@ const truncateText = (text, maxLength) => {
<Card
sx={{
cursor: "pointer",
"&:hover": { backgroundColor: "#0e467f", color: "#ffffff", transform: "scale(1.05)", boxShadow: 6 },
"&:hover": { backgroundColor: "#0e467f", color: "white", transform: "scale(1.05)", boxShadow: 6 },
}}
onClick={() => navigate(`/post/${post.slug}`)}
onClick={() => navigate(`/post/${post.id}`)}
>
{post.image && (
<CardMedia component="img" height="200" image={post.image} alt={post.title.rendered} />
)}
<CardContent>
<Typography variant="h2" sx={{ fontWeight: "bold", fontSize: "1.5em", color:"#315397"}}>
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
{post.title.rendered}
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{truncateText(post.excerpt.rendered || post.content.rendered, 100)}
{truncateText(post.excerpt.rendered, 100)}
</Typography>
<Button variant="outlined" sx={{ mt: 2, color: "white", borderColor: "white" }}>
Lire plus
+11 -73
View File
@@ -1,89 +1,27 @@
import { useEffect, useState } from "react";
import { fetchPosts, deletePost } from "../../wordpress";
import { Box, Typography, Button, CircularProgress, Paper, Grid } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadPosts() {
try {
const data = await fetchPosts();
setPosts(data);
} catch (err) {
setError("⚠️ Impossible de récupérer les articles.");
console.error("Erreur lors de la récupération des articles :", err);
} finally {
setLoading(false);
}
const data = await fetchPosts();
setPosts(data);
}
loadPosts();
}, []);
const handleDelete = async (postId) => {
if (window.confirm("❌ Es-tu sûr de vouloir supprimer cet article ?")) {
try {
await deletePost(postId);
setPosts(posts.filter((post) => post.id !== postId));
toast.success("✅ Article supprimé avec succès !");
} catch (err) {
console.error("Erreur suppression :", err);
toast.error("⚠️ Erreur lors de la suppression.");
}
}
};
return (
<Box sx={{ maxWidth: "900px", margin: "auto", mt: 5 }}>
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}>
📋 Liste des Articles
</Typography>
{loading && (
<Box sx={{ textAlign: "center", mt: 4 }}>
<CircularProgress />
</Box>
)}
{error && (
<Typography color="error" textAlign="center" sx={{ mb: 3 }}>
{error}
</Typography>
)}
{!loading && posts.length === 0 && (
<Typography textAlign="center" sx={{ mt: 3 }}>
Aucun article trouvé.
</Typography>
)}
<Grid container spacing={3}>
{posts.map((post) => (
<Grid item xs={12} sm={6} md={4} key={post.id}>
<Paper sx={{ padding: 3, boxShadow: 3, textAlign: "center" }}>
<Typography variant="h6" sx={{ fontWeight: "bold", mb: 2 }}>
{post.title.rendered}
</Typography>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleDelete(post.id)}
sx={{ fontWeight: "bold" }}
>
Supprimer
</Button>
</Paper>
</Grid>
))}
</Grid>
</Box>
<div className="post-list">
<h2>Liste des Articles</h2>
{posts.map((post) => (
<div key={post.id} className="post-item">
<h3>{post.title.rendered}</h3>
<button onClick={() => deletePost(post.id)}>Supprimer</button>
</div>
))}
</div>
);
}
export default PostList;
@@ -1,111 +0,0 @@
import React, { useState, useEffect } from "react";
import ServicePageTemplate from "../ServicePageTemplate";
import { getPageById } from "../../wordpress";
const ServiceCinq = () => {
const [acfData, setAcfData] = useState(null);
const [loading, setLoading] = useState(true);
const pageId = 612; // 🔥 Remplace par l'ID réel de la page WordPress
useEffect(() => {
const fetchACFData = async () => {
try {
const pageData = await getPageById(pageId);
setAcfData(pageData.acf);
} catch (error) {
console.error("❌ Erreur récupération des champs ACF :", error);
} finally {
setLoading(false);
}
};
fetchACFData();
}, []);
// Affichage du chargement en attendant les données ACF
if (loading) {
return <p>Chargement...</p>;
}
const getStoredValue = (key) =>
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
const heroBackground =
acfData?.hero_background ||
acfData?.background_hero ||
acfData?.hero_bg ||
storedHeroBackground ||
"linear-gradient(135deg, #050d1f 0%, #02050d 85%)";
const heroImage =
acfData?.hero_background_image ||
acfData?.hero_background_image_url ||
acfData?.hero_image_url ||
acfData?.hero_image ||
acfData?.image_hero ||
acfData?.img_hero_url ||
acfData?.img_hero ||
storedHeroImage ||
"https://picsum.photos/id/1026/1920/1080.webp";
const serviceDetails = {
// Hero
title: acfData?.titre_principal_tce || "Titre héros !", // Modifiable individuellement
subtitle:
acfData?.description_principal_tce ||
"<p>Description, héros</p>",
image: heroImage,
heroBackground,
ctaText: "En savoir plus",
ctaLink: "/contact",
// section 1
interestTitle: acfData?.second_titre_tce || "Titre interet", // Modifiable individuellement
description:
acfData?.second_description_tce || "<p>Description interet</p>",
// section 2
desirTitle: "Titre", // Modifiable individuellement
features: [
{
title: acfData?.titre_carte_one_tce || "Titre !",
description: acfData?.description_carte_one_tce || "Description.",
modalText: acfData?.description_carte_one_tce || "Description Modal.",
modalImage: "https://picsum.photos/id/300/800/400.webp",
},
{
title: acfData?.titre_carte_two_tce || "Titre !",
description: acfData?.description_carte_two_tce || "Description.",
modalText: acfData?.description_carte_two_tce || "Description Modal.",
modalImage: "https://picsum.photos/id/301/800/400.webp",
},
{
title: acfData?.titre_carte_tree || "Titre !",
description: acfData?.description_carte_tree || "Description.",
modalText: acfData?.description_carte_tree || "Description Modal.",
modalImage: "https://picsum.photos/id/302/800/400.webp",
},
],
carouselItems: [
{
title: "Portfolio Modern",
description: "Montrez vos compétences avec un portfolio unique.",
image: "https://picsum.photos/id/305/800/400.webp",
},
{
title: "Blog SEO",
description: "Optimisez vos articles pour le référencement.",
image: "https://picsum.photos/id/306/800/400.webp",
},
],
};
return <ServicePageTemplate {...serviceDetails} />;
};
export default ServiceCinq;
+32 -90
View File
@@ -1,109 +1,51 @@
import React, { useState, useEffect } from "react";
import ServicePageTemplate from "../ServicePageTemplate";
import { getPageById } from "../../wordpress";
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
const ServiceDeux = () => {
const [acfData, setAcfData] = useState(null);
const [loading, setLoading] = useState(true);
const pageId = 671; // 🔥 Remplace par l'ID réel de la page WordPress
useEffect(() => {
const fetchACFData = async () => {
try {
const pageData = await getPageById(pageId);
setAcfData(pageData.acf);
} catch (error) {
console.error("❌ Erreur récupération des champs ACF :", error);
} finally {
setLoading(false);
}
};
fetchACFData();
}, []);
// Affichage du chargement en attendant les données ACF
if (loading) {
return <p>Chargement...</p>;
}
const getStoredValue = (key) =>
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
const heroBackground =
acfData?.hero_background ||
acfData?.background_hero ||
acfData?.hero_bg ||
storedHeroBackground ||
"linear-gradient(135deg, #041226 0%, #01040a 90%)";
const heroImage =
acfData?.hero_background_image ||
acfData?.hero_background_image_url ||
acfData?.hero_image_url ||
acfData?.hero_image ||
acfData?.image_hero ||
acfData?.img_hero_url ||
acfData?.img_hero ||
storedHeroImage ||
"https://picsum.photos/id/240/1920/1080.webp";
const serviceDetails = {
// Hero
title: acfData?.titre_principal_beton || "formations-web",
subtitle:
acfData?.description_principal_beton ||
"<p>Solidité, Résistance, Durabilité</p>",
image: heroImage,
heroBackground,
ctaText: "En savoir plus !",
ctaLink: "/contact",
// section 1
interestTitle: acfData?.second_titre_beton ||"Béton ou Charpente :\n Quel Matériau Choisir pour une Construction Durable ?", // Modifiable individuellement
title: 'Structure béton charpente métallique & bois',
subtitle: 'Dominez lintelligence artificielle.',
description:
acfData?.second_description_beton ||
"<p>Le béton est le matériau de construction le plus répandu dans le monde. Il est présent dans tous les secteurs de la construction.</p><p>Longtemps associé à limage négative des grands ensembles, gris et vieillissants, le béton a réalisé des progrès spectaculaires ces dernières années, tant au niveau de ses performances techniques que des aspects esthétiques.</p><p>Il commence même à entrer dans les foyers comme produit de décoration ! Une charpente est un assemblage de pièces de bois et/ou de métal, servant à soutenir ou couvrir des constructions et faisant partie de la toiture.</p>",
// section 2
desirTitle: acfData?.troisieme_titre_beton || "Titre",// Modifiable individuellement
'Plongez dans le futur! Notre formation IA vous prépare aux outils modernes dintelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
features: [
{
title: acfData?.titre_carte_one_beton || "Titre !",
description: acfData?.description_carte_one_beton || "Description.",
modalText: acfData?.description_carte_one_beton || "Description Modal.",
modalImage: "https://picsum.photos/id/200/800/400.webp",
title: 'Fondamentaux de lIA',
description: 'Concepts, algorithmes et applications.',
modalText: 'Découvrez les bases de lIA, des algorithmes aux applications concrètes dans divers domaines.',
modalImage: 'https://picsum.photos/id/200/800/400.webp',
},
{
title: acfData?.titre_carte_two_beton || "Titre !",
description: acfData?.description_carte_two_beton || "Description.",
modalText: acfData?.description_carte_two_beton || "Description Modal.",
modalImage: "https://picsum.photos/id/201/800/400.webp",
title: 'Python pour lIA',
description: 'Bibliothèques populaires: TensorFlow, PyTorch.',
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles dIA puissants.',
modalImage: 'https://picsum.photos/id/201/800/400.webp',
},
{
title: acfData?.titre_carte_tree_beton || "Titre !",
description: acfData?.description_carte_tree_beton || "Description.",
modalText: acfData?.description_carte_tree_beton || "Description Modal.",
modalImage: "https://picsum.photos/id/202/800/400.webp",
title: 'Projets Pratiques',
description: 'Créez vos propres modèles IA.',
modalText: 'Mettez vos compétences en pratique en développant des projets IA réels.',
modalImage: 'https://picsum.photos/id/202/800/400.webp',
},
],
image: 'https://picsum.photos/id/239/1920/1080.webp',
ctaText: 'Découvrir la formation IA',
ctaLink: '/contact',
carouselItems: [
{
title: "Structure beton",
description: "Un projet e-commerce performant et moderne.",
image: "https://picsum.photos/id/522/800/400.webp",
title: 'Site E-commerce',
description: 'Un projet e-commerce performant et moderne.',
image: 'https://picsum.photos/id/453/800/400.webp',
},
{
title: "Structure bois",
description: "Montrez vos compétences avec un portfolio sur mesure.",
image: "https://picsum.photos/id/521/800/400.webp",
title: 'Portfolio Personnel',
description: 'Montrez vos compétences avec un portfolio sur mesure.',
image: 'https://picsum.photos/id/454/800/400.webp',
},
{
title: 'Blog Dynamique',
description: 'Créez un blog interactif et optimisé pour le SEO.',
image: 'https://picsum.photos/id/455/800/400.webp',
},
],
};
@@ -1,141 +0,0 @@
import React, { useState, useEffect } from "react";
import { Helmet } from "react-helmet-async"; // SEO
import ServicePageTemplate from "../ServicePageTemplate";
import { getPageById } from "../../wordpress";
const ServiceQuatre = () => {
const [acfData, setAcfData] = useState(null);
const [seoData, setSeoData] = useState(null); // Stocke les données SEO de RankMath
const [loading, setLoading] = useState(true);
const pageId = 590; // 🔥 ID réel de la page WordPress
useEffect(() => {
const fetchPageData = async () => {
try {
const pageData = await getPageById(pageId);
// Vérification des données ACF
if (pageData.acf) {
setAcfData(pageData.acf);
} else {
console.warn("⚠ Aucune donnée ACF trouvée !");
}
// Récupération des données SEO depuis RankMath
if (pageData.rankMath?.assessor?.serpData) {
const serpData = pageData.rankMath.assessor.serpData;
console.log("📢 Données SEO récupérées :", serpData);
setSeoData({
metaTitle: serpData.title || pageData.title?.rendered || "Titre par défaut",
metaDescription: serpData.description || "Description par défaut",
keywords: serpData.focusKeywords || "bureau d'études, construction, rénovation",
ogImage: serpData.ogImage || "https://picsum.photos/id/1018/1920/1080.webp",
});
} else {
console.warn("⚠ Aucune donnée SEO trouvée dans RankMath !");
}
} catch (error) {
console.error("❌ Erreur récupération des champs ACF et SEO :", error);
} finally {
setLoading(false);
}
};
fetchPageData();
}, []);
if (loading) {
return <p>Chargement...</p>;
}
const getStoredValue = (key) =>
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
const heroBackground =
acfData?.hero_background ||
acfData?.background_hero ||
acfData?.hero_bg ||
storedHeroBackground ||
"linear-gradient(135deg, #07132a 0%, #01040c 90%)";
const heroImage =
acfData?.hero_background_image ||
acfData?.hero_background_image_url ||
acfData?.hero_image_url ||
acfData?.hero_image ||
acfData?.image_hero ||
acfData?.img_hero_url ||
acfData?.img_hero ||
storedHeroImage ||
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-incendie.webp";
const serviceDetails = {
title: acfData?.titre_principal || "Titre héros !",
subtitle: acfData?.description_principal || "<p>Description, héros</p>",
image: heroImage,
heroBackground,
ctaText: "En savoir plus",
ctaLink: "/contact",
interestTitle: acfData?.second_titre || "Titre interet",
description: acfData?.second_description || "<p>Description interet</p>",
desirTitle: "Titre",
features: [
{
title: acfData?.titre_carte_one || "Titre !",
description: acfData?.description_carte_one || "",
modalText: acfData?.description_carte_one || "Description Modal.",
modalImage: "https://picsum.photos/id/300/800/400.webp",
},
{
title: acfData?.titre_carte_two || "Titre !",
description: acfData?.description_carte_two || "",
modalText: acfData?.description_carte_two || "Description Modal.",
modalImage: "https://picsum.photos/id/301/800/400.webp",
},
{
title: acfData?.titre_carte_tree || "Titre !",
description: acfData?.description_carte_tree || "",
modalText: acfData?.description_carte_tree || "Description Modal.",
modalImage: "https://picsum.photos/id/302/800/400.webp",
},
],
carouselItems: [
{
title: "Portfolio Modern",
description: "Description de l'image",
image: "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/securite-incendie.webp",
width: 800,
height: 400,
},
{
title: "service securité S.S.I",
description: "Description de l'image",
image: "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-ssi.webp",
width: 800,
height: 400,
},
],
// Ajout du SEO dynamique récupéré via RankMath
seo: {
metaTitle: seoData?.metaTitle || "Pourquoi faire appel à un bureau d’études ?",
metaDescription: seoData?.metaDescription || "Description SEO par défaut",
keywords: seoData?.keywords || "bureau d'études, construction, rénovation",
ogImage: seoData?.ogImage || "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-incendie.webp",
},
};
return <ServicePageTemplate {...serviceDetails} />
};
export default ServiceQuatre;
+30 -48
View File
@@ -1,70 +1,52 @@
import React from "react";
import ServicePageTemplate from "../ServicePageTemplate";
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
const ServiceTrois = () => {
const serviceDetails = {
// Hero
title: "Électricité", // Modifiable individuellement
subtitle: "IN3 est un bureau d’études technique spécialisé dans lingénierie électrique du bâtiment.",
image: "https://picsum.photos/id/1019/1920/1080.webp",
heroBackground: "linear-gradient(135deg,#0c101f 0%,#03050b 85%)",
ctaText: "En savoir plus",
ctaLink: "/contact",
// Section 1
interestTitle: "Comment réussir vos installations électriques \navec une assistance experte en maîtrise douvrage ?",
description: "Notre activité est lassistance aux maîtres douvrage à la réalisation des installations électriques courants forts et courants faibles.\n Définition des besoins / Audit si installations existantes Estimation du coût des ouvrages.\n \n Plans dimplantation du matériel et des canalisations schémas électriques unifilaires.\n Analyse des offres et assistance technique au choix de lentreprise d’électricité.",
// Section 2
desirTitle: "Réalisation et assistance électrique",
title: 'Électricité',
subtitle: 'IN3 est un bureau d’études technique spécialisé dans lingénierie électrique du bâtiment.',
description:
'Plongez dans le futur! Notre formation IA vous prépare aux outils modernes dintelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
features: [
{
title: "Chantier électricité industriel",
description: "Réalisation dinstallations électriques.",
modalText: "Réalisation dinstallations électriques pour des sites industriels, incluant le câblage, la pose d’équipements, et la mise en conformité selon les normes en vigueur.",
modalImage: "https://picsum.photos/id/200/800/400.webp",
title: 'Chantier électricité industriel',
description: 'Réalisation dinstallations électriques.',
modalText: 'Réalisation dinstallations électriques pour des sites industriels, incluant le câblage, la pose d’équipements, et la mise en conformité selon les normes en vigueur..',
modalImage: 'https://picsum.photos/id/200/800/400.webp',
},
{
title: "Dossier de Consultation",
description: "Réalisation du Dossier de Consultation des entreprises (DCE)",
modalText: "Réalisation du Dossier de Consultation des entreprises (DCE) cahier des clauses techniques particulières (CCTP) bordereau de décomposition du prix global et forfaitaire (DPGF, servant de base à tous les devis).",
modalImage: "https://picsum.photos/id/201/800/400.webp",
title: 'Python pour lIA',
description: 'Bibliothèques populaires: TensorFlow, PyTorch.',
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles dIA puissants.',
modalImage: 'https://picsum.photos/id/201/800/400.webp',
},
{
title: "L'électricité & Suivi de chantier",
description: "Assistance à lentreprise et vérification des documents",
modalText: "Assistance à lentreprise et vérification des documents dexécution. Réception des ouvrages exécutés. Nous pouvons également assister lentreprise titulaire du marché à la réalisation des documents dexécution (plans dimplantation, schématique électrique, notes de calculs réglementaires, …)",
modalImage: "https://picsum.photos/id/202/800/400.webp",
title: 'Projets Pratiques',
description: 'Créez vos propres modèles IA.',
modalText: 'Mettez vos compétences en pratique en développant des projets IA réels.',
modalImage: 'https://picsum.photos/id/202/800/400.webp',
},
],
image: 'https://picsum.photos/id/239/1920/1080.webp',
ctaText: 'En savoir plus !',
ctaLink: '/contact',
carouselItems: [
{
title: "Site E-commerce",
description: "Un projet e-commerce performant et moderne.",
image: "https://picsum.photos/id/453/800/400.webp",
title: 'Site E-commerce',
description: 'Un projet e-commerce performant et moderne.',
image: 'https://picsum.photos/id/453/800/400.webp',
},
{
title: "Portfolio Personnel",
description: "Montrez vos compétences avec un portfolio sur mesure.",
image: "https://picsum.photos/id/454/800/400.webp",
title: 'Portfolio Personnel',
description: 'Montrez vos compétences avec un portfolio sur mesure.',
image: 'https://picsum.photos/id/454/800/400.webp',
},
{
title: "Blog Dynamique",
description: "Créez un blog interactif et optimisé pour le SEO.",
image: "https://picsum.photos/id/455/800/400.webp",
title: 'Blog Dynamique',
description: 'Créez un blog interactif et optimisé pour le SEO.',
image: 'https://picsum.photos/id/455/800/400.webp',
},
],
// Métadonnées SEO
seo: {
metaTitle: "Électricité | Expertise en ingénierie électrique",
metaDescription: "Découvrez notre expertise en ingénierie électrique et maîtrisez l'installation et la gestion des systèmes électriques.",
keywords: "électricité, ingénierie électrique, installation électrique, suivi de chantier",
ogImage: "https://picsum.photos/1200/630",
},
};
return <ServicePageTemplate {...serviceDetails} />;
+26 -53
View File
@@ -1,74 +1,47 @@
import React from "react";
import ServicePageTemplate from "../ServicePageTemplate";
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
const ServiceUn = () => {
const serviceDetails = {
// Hero
title:
"Pourquoi faire appel à un \n bureau d’études pour concevoir \n ou rénover votre bâtiment ?", // Modifiable individuellement
subtitle:
"Notre bureau d’études in3 au Mans conçoit des bâtiments à construire ou à rénover selon le programme fourni par le maître de louvrage, de diriger lexécution des marchés de travaux, de proposer le règlement des travaux et leur réception. Nos missions sont les suivantes :", // Modifiable individuellement
image: "https://picsum.photos/id/1026/1920/1080.webp",
heroBackground: "linear-gradient(135deg,#0b1428 0%,#16223f 40%,#04070f 100%)",
ctaText: "En savoir plus",
ctaLink: "/contact",
// section 1
interestTitle:
"Comment le maître d’œuvre garantit-il \n la réussite de votre projet de construction ?", // Modifiable individuellement
title: 'Prestation-maitrise-oeuvre',
subtitle: 'Apprenez à maîtriser les technologies du web.',
description:
"Véritable bras droit du maître douvrage, nous lui proposons une solution technique et esthétique qui permet de réaliser son programme, dans lenveloppe budgétaire et les délais qui lui sont assignés. Une fois son projet validé par le maître douvrage, le maître d’œuvre est responsable du bon déroulement des travaux et joue un rôle de conseil dans le choix des entreprises qui vont les réaliser. Le choix de lentrepreneur (ou des entrepreneurs) se fait à partir dune consultation formalisée où, sur la base dun cahier des charges (notamment le Cahier des Clauses Techniques Particulières), le titulaire faisant loffre la plus adaptée, est choisi par le maître douvrage sur proposition du maître d’œuvre compte tenu d’éléments matériels concrets.", // Modifiable individuellement
// section 2
desirTitle: "Titre Désir", // Modifiable individuellement
'Notre formation web vous permet de développer vos compétences en développement frontend, backend et design. Rejoignez notre programme pour apprendre HTML, CSS, JavaScript et bien plus.',
features: [
{
title: "diagnostic \n (DIA)",
description: "Les études de diagnostic (DIA)...",
modalText:
"Les études de diagnostic (DIA), pour le cas de travaux sur un bâtiment existant)",
modalImage: "https://picsum.photos/id/300/800/400.webp",
title: 'Frontend Development',
description: 'HTML, CSS, JavaScript, React et plus encore.',
modalText: 'Apprenez à construire des interfaces utilisateurs modernes avec les technologies frontend.',
modalImage: 'https://picsum.photos/id/300/800/400.webp',
},
{
title: "Les Études dEsquisse \n (ESQ)",
description: "Comprendre leur Importance dans un Projet Architectural",
modalText:
"Lobjectif principal de cette phase est de poser les bases du projet en définissant les grandes lignes de limplantation, de lorganisation des espaces et de lesthétique du bâtiment. Cela inclut : Lanalyse du site : étude de lenvironnement, des accès, de lorientation et des contraintes liées au terrain. L’évaluation des besoins du maître douvrage : prise en compte des attentes fonctionnelles et esthétiques. La proposition de plusieurs variantes : différentes approches sont étudiées pour identifier la meilleure solution. Une estimation préliminaire des coûts : pour vérifier ladéquation entre les ambitions du projet et le budget disponible.",
modalImage: "https://picsum.photos/id/301/800/400.webp",
title: 'Backend Development',
description: 'Node.js, Express, MongoDB et bases de données.',
modalText: 'Développez des applications robustes avec des technologies backend performantes.',
modalImage: 'https://picsum.photos/id/301/800/400.webp',
},
{
title: "Pourquoi réaliser une étude davant-projet ?",
description: "Les Études dAvant-Projet (AVP)...",
modalText:
"LAVP a pour objectif de définir les grandes lignes du projet en sappuyant sur une analyse approfondie des besoins, des contraintes et des attentes des parties prenantes. Elle permet de : Clarifier les objectifs : Identifier précisément les résultats attendus et les enjeux du projet. Évaluer la faisabilité : Étudier la viabilité technique, économique et réglementaire. Définir les solutions possibles : Comparer différentes approches et choisir la meilleure. Établir une première estimation des coûts et délais : Anticiper les ressources nécessaires et éviter les imprévus.",
modalImage: "https://picsum.photos/id/302/800/400.webp",
title: 'Responsive Design',
description: 'Apprenez à créer des designs modernes et adaptatifs.',
modalText: 'Maîtrisez les principes du responsive design pour des expériences utilisateur optimales.',
modalImage: 'https://picsum.photos/id/302/800/400.webp',
},
],
image: 'https://picsum.photos/id/1018/1920/1080.webp',
ctaText: 'En savoir plus',
ctaLink: '/contact',
carouselItems: [
{
title: "préstation maitrise d'oeuvre",
description: "Montrez vos compétences avec un portfolio unique.",
image: "https://picsum.photos/id/307/800/400.webp",
title: 'Portfolio Modern',
description: 'Montrez vos compétences avec un portfolio unique.',
image: 'https://picsum.photos/id/305/800/400.webp',
},
{
title: "Bureau d'études maitrise-oeuvre",
description: "Optimisez vos articles pour le référencement.",
image: "https://picsum.photos/id/308/800/400.webp",
title: 'Blog SEO',
description: 'Optimisez vos articles pour le référencement.',
image: 'https://picsum.photos/id/306/800/400.webp',
},
],
// Métadonnées SEO
seo: {
metaTitle:
"Pourquoi faire appel à un bureau d’études pour votre construction ou rénovation ? | in3",
metaDescription:
"Optimisez votre projet de construction ou de rénovation avec notre bureau d’études in3. Expertise, maîtrise d’œuvre et solutions adaptées pour garantir la réussite de votre chantier.",
keywords:
"bureau d'études, construction, rénovation, maîtrise d’œuvre, étude avant-projet, diagnostic DIA, architecture, travaux, bâtiment",
ogImage: "https://picsum.photos/id/1018/1920/1080.webp",
},
};
return <ServicePageTemplate {...serviceDetails} />;
-229
View File
@@ -1,229 +0,0 @@
import { useEffect } from "react";
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 = [
{
id: "app-reseau",
name: "App Réseau",
description: "Application React dédiée à la gestion du réseau.",
url: "/app-reseaux/index.html?access-token=OCTO-ADMIN-2024",
external: true,
},
{
id: "generateur-photo",
name: "Generateur Photos",
description: "Application de génération d'image.",
url: "/app-photos/index.html?access-token=OCTO-ADMIN-2024",
external: true,
},
{
id: "app-planning",
name: "Planning",
description: "Application de planification.",
url: "https://planning-arinfo.vercel.app/",
external: true,
},
];
function Tools() {
const navigate = useNavigate();
const token = getToken();
useEffect(() => {
if (!token) {
navigate("/admin/login");
}
}, [token, navigate]);
const handleOpenApp = (app) => {
if (app.external) {
window.open(app.url, "_blank", "noopener");
return;
}
navigate(app.url);
};
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 (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate("/admin/dashboard")}
sx={composeButtonSx("outline")}
>
Retour au dashboard
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Hub applicatif Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Mes outils métiers
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Centralisez vos applications internes : réseau, générateur
de photos et futurs modules dautomatisation Octopus.
</Typography>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Accès sécurisé"
sx={{
bgcolor: "rgba(255,255,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Applications internes Octopus"
sx={{
bgcolor: "rgba(123,192,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
<Grid container spacing={3} rowSpacing={9}>
{toolsApps.map((app) => (
<Grid item xs={12} md={6} lg={4} key={app.id}>
<Card
className="glass-card"
sx={{
borderRadius: 4,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
p: { xs: 2.5, md: 3 },
}}
>
<Stack spacing={1.8}>
<Typography
variant="overline"
sx={{ color: "rgba(11, 26, 61, 0.65)", letterSpacing: 1 }}
>
{app.external ? "Application externe" : "Module interne"}
</Typography>
<Typography
variant="h6"
sx={{ color: "#cfd2d8ff", fontWeight: 700 }}
>
{app.name}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
>
{app.description}
</Typography>
</Stack>
<Button
variant="contained"
sx={composeButtonSx("primary")}
onClick={() => handleOpenApp(app)}
>
Ouvrir
</Button>
</Card>
</Grid>
))}
</Grid>
</Box>
</Box>
);
}
export default Tools;
+28 -52
View File
@@ -1,88 +1,64 @@
import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { Box, Typography, Button, CircularProgress } from "@mui/material";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api";
const PostDetails = () => {
const { slug } = useParams();
const { id } = useParams();
const [post, setPost] = useState(null);
const [image, setImage] = useState(null);
const navigate = useNavigate();
useEffect(() => {
const fetchPost = async () => {
if (!slug) {
console.error("❌ Erreur : aucun slug fourni !");
return;
}
try {
console.log(`📢 Requête API pour le slug : ${slug}`);
const response = await api.get(`wp/v2/posts?slug=${slug}&_fields=id,title,content,featured_media,date`);
const response = await api.get(`wp/v2/posts/${id}?_fields=title,content,featured_media,date`);
setPost(response.data);
if (response.data.length > 0) {
const article = response.data[0];
setPost(article);
if (article.featured_media) {
console.log(`📢 Récupération de l'image ID : ${article.featured_media}`);
const mediaResponse = await api.get(`wp/v2/media/${article.featured_media}`);
setImage(mediaResponse.data.source_url);
}
} else {
console.error("❌ Aucun article trouvé avec ce slug !");
// Récupération de l'image
if (response.data.featured_media) {
const mediaResponse = await api.get(`wp/v2/media/${response.data.featured_media}`);
setImage(mediaResponse.data.source_url);
}
} catch (error) {
console.error("Erreur lors de la récupération de l'article :", error);
console.error("Erreur lors de la récupération de l'article :", error);
}
};
fetchPost();
}, [slug]);
}, [id]);
if (!post) {
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
}
return (
<Box sx={{ padding: "40px 20px", display: "flex", flexDirection: "column" }}>
<Button onClick={() => navigate(-1)} variant="contained" sx={{ mt: 5, mb: 3, maxWidth: "200px" }}>
<Box sx={{ padding: "40px 20px",display:"flex", flexDirection:"column" }}>
<Button onClick={() => navigate(-1)} variant="outlined" sx={{ mt:5, mb: 3, maxWidth:"200px" }}>
Retour
</Button>
<Typography
variant="h1"
sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: "3rem", md: "3rem", lg: "3rem" },
}}
>
<Typography variant="h1" sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: '3rem', md: '3rem', lg: '3rem' }, // Responsive
}}>
{post.title.rendered}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "40px 20px", margin: "auto" }}>
{image && (
<Box
component="img"
src={image}
alt={post.title.rendered}
sx={{ width: "100%", maxHeight: "400px", objectFit: "cover", borderRadius: "10px 0 10px 10px", mb: 3 }}
/>
)}
<Box sx={{ display:"flex", alignItems:"center", justifyContent:"center", padding: "40px 20px",margin:"auto" }}>
{image && (
<Box
sx={{
maxWidth: "800px",
mx: "auto",
"& h2": { color: "#315397", mt: 4, mb: 2 },
"& h3": { color: "#315397", mt: 3, mb: 1 },
"& h4": { color: "#315397", mt: 2, mb: 1 },
"& p": { mb: 2 },
}}
dangerouslySetInnerHTML={{ __html: post.content.rendered }}
component="img"
src={image}
alt={post.title.rendered}
sx={{ width: "33%", maxHeight: "400px", objectFit: "cover", borderRadius: "8px", mb: 3 }}
/>
)}
<Typography variant="body1" sx={{ maxWidth: "800px", mx: "auto" }} dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</Box>
</Box>
);
+44 -248
View File
@@ -1,275 +1,71 @@
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import api from "../api";
const PRODUCTION_ORIGIN = "https://octopusdesign.fr";
const PREPROD_HOST = "preprod.octopusdesign.fr";
const PATH_PREFIX_TO_STRIP = "/api-octopus/server";
const FORCE_NO_INDEX = true;
const DEFAULT_OG_IMAGE =
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif";
const DEFAULT_ROBOTS = FORCE_NO_INDEX ? "noindex, nofollow" : "index, follow";
const getWindowHref = () =>
typeof window !== "undefined" ? window.location.href : "";
const shouldNormalize = (value) =>
typeof value === "string" &&
(value.includes(PREPROD_HOST) || value.includes(PATH_PREFIX_TO_STRIP) || value.startsWith("http://127.") || value.includes("localhost"));
const normalizeToProduction = (value) => {
if (typeof value !== "string" || value.length === 0) return value;
const trimmed = value.trim();
const keepTrailingSlash = trimmed.endsWith("/") && !trimmed.endsWith("//");
try {
const reference = new URL(PRODUCTION_ORIGIN);
const url = new URL(trimmed, PRODUCTION_ORIGIN);
const mustForceProductionHost =
url.hostname === PREPROD_HOST ||
url.hostname === "127.0.0.1" ||
url.hostname === "localhost";
if (mustForceProductionHost) {
url.protocol = reference.protocol;
url.hostname = reference.hostname;
url.port = "";
}
if (url.pathname.startsWith(PATH_PREFIX_TO_STRIP)) {
const stripped = url.pathname.replace(PATH_PREFIX_TO_STRIP, "") || "/";
url.pathname = stripped;
}
const shouldAppendSlash =
!url.pathname.includes(".") &&
url.pathname !== "/" &&
!url.pathname.endsWith("/") &&
(keepTrailingSlash || mustForceProductionHost || url.hostname === reference.hostname);
if (shouldAppendSlash) {
url.pathname = `${url.pathname}/`;
}
if (keepTrailingSlash && url.pathname === "/") {
url.pathname = "/";
}
const normalized = url.toString().replace(/([^:]\/)\/+/g, "$1");
return normalized;
} catch (error) {
if (shouldNormalize(trimmed)) {
return trimmed
.replace("https://preprod.octopusdesign.fr/api-octopus/server", PRODUCTION_ORIGIN)
.replace("https://preprod.octopusdesign.fr", PRODUCTION_ORIGIN)
.replace("http://127.0.0.1", PRODUCTION_ORIGIN)
.replace("http://localhost", PRODUCTION_ORIGIN);
}
return value;
}
};
const normalizeAttributeUrls = (attributes) => {
if (!attributes) return attributes;
const next = { ...attributes };
if (shouldNormalize(next.href)) {
next.href = normalizeToProduction(next.href);
}
if (shouldNormalize(next.content)) {
next.content = normalizeToProduction(next.content);
}
return next;
};
import axios from "axios";
const SEO = ({
pageUrl,
postId,
defaultTitle = "Titre par défaut",
defaultDescription = "Description par défaut",
defaultCanonicalUrl = "",
defaultOgImage = DEFAULT_OG_IMAGE,
defaultOgImage = "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
}) => {
const [rankMathHead, setRankMathHead] = useState(null);
const [shouldFallback, setShouldFallback] = useState(true);
const runtimeUrl = getWindowHref();
const rankMathTargetUrl = pageUrl || defaultCanonicalUrl || runtimeUrl;
const normalizedPageUrl = normalizeToProduction(pageUrl);
const resolvedCanonical =
normalizeToProduction(defaultCanonicalUrl) ||
normalizedPageUrl ||
normalizeToProduction(runtimeUrl) ||
PRODUCTION_ORIGIN;
const targetUrl =
normalizedPageUrl ||
resolvedCanonical ||
normalizeToProduction(rankMathTargetUrl) ||
PRODUCTION_ORIGIN;
const [metaData, setMetaData] = useState({
title: defaultTitle,
description: defaultDescription,
canonicalUrl: defaultCanonicalUrl,
ogTitle: defaultTitle,
ogDescription: defaultDescription,
ogImage: defaultOgImage,
});
useEffect(() => {
if (!rankMathTargetUrl) return;
if (!postId) return;
const controller = new AbortController();
const fetchRankMathHead = async () => {
const fetchMetaData = async () => {
try {
const { data } = await api.get("rankmath/v1/getHead", {
params: { url: rankMathTargetUrl },
signal: controller.signal,
});
const { data } = await axios.get(
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${postId}`
);
if (
data?.success &&
data.head &&
typeof window !== "undefined" &&
window.DOMParser
) {
const parser = new window.DOMParser();
const parsedDocument = parser.parseFromString(
`<!doctype html><html><head>${data.head}</head><body></body></html>`,
"text/html"
);
const headElement = parsedDocument.head;
const attributesToObject = (element) =>
Array.from(element.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {});
const metaTags = Array.from(headElement.querySelectorAll("meta")).map(
(meta) => normalizeAttributeUrls(attributesToObject(meta))
);
const linkTags = Array.from(headElement.querySelectorAll("link")).map(
(link) => normalizeAttributeUrls(attributesToObject(link))
);
const scriptTags = Array.from(
headElement.querySelectorAll("script")
).map((script) => ({
attributes: attributesToObject(script),
innerHTML: script.innerHTML,
if (data && data.rank_math_og) {
setMetaData((prev) => ({
...prev,
title: data.rank_math_og.og_title || prev.title,
description: data.rank_math_og.og_description || prev.description,
ogTitle: data.rank_math_og.og_title || prev.title,
ogDescription: data.rank_math_og.og_description || prev.description,
ogImage: data.rank_math_og.og_image || prev.ogImage,
canonicalUrl: data.acf?.canonical_url || window.location.href
}));
setRankMathHead({
title: headElement.querySelector("title")?.textContent || "",
meta: metaTags,
links: linkTags,
scripts: scriptTags,
});
setShouldFallback(false);
} else {
setRankMathHead(null);
setShouldFallback(true);
}
} catch (error) {
if (!controller.signal.aborted) {
console.error(
"⚠️ Erreur lors de la récupération des métadonnées Rank Math :",
error
);
}
setRankMathHead(null);
setShouldFallback(true);
console.error("⚠️ Erreur lors de la récupération des métadonnées SEO :", error);
}
};
fetchRankMathHead();
return () => controller.abort();
}, [targetUrl]);
const rankMathNodes = useMemo(() => {
if (!rankMathHead) return null;
const sanitizeMeta = (meta = []) => {
const filtered = meta.filter((attributes) => {
const name = attributes?.name?.toLowerCase();
if (name === "robots") {
if (FORCE_NO_INDEX) {
return false;
}
const content = attributes.content?.toLowerCase() || "";
if (content.includes("noindex") || content.includes("nofollow")) {
return false;
}
}
return true;
});
const hasRobotsTag = filtered.some(
(attributes) => attributes?.name?.toLowerCase() === "robots"
);
const withRobots = FORCE_NO_INDEX
? [...filtered, { name: "robots", content: DEFAULT_ROBOTS }]
: hasRobotsTag
? filtered
: [...filtered, { name: "robots", content: DEFAULT_ROBOTS }];
return withRobots.map((attributes) => normalizeAttributeUrls(attributes));
};
const sanitizedMeta = sanitizeMeta(rankMathHead.meta);
const renderMetaTags = sanitizedMeta.map((attributes, index) => (
<meta key={`rank-math-meta-${index}`} {...attributes} />
));
const renderLinkTags = rankMathHead.links.map((attributes, index) => (
<link key={`rank-math-link-${index}`} {...attributes} />
));
const renderScriptTags = rankMathHead.scripts.map((script, index) => (
<script
key={`rank-math-script-${index}`}
{...script.attributes}
dangerouslySetInnerHTML={{ __html: script.innerHTML }}
/>
));
return (
<>
{rankMathHead.title && <title>{rankMathHead.title}</title>}
{renderMetaTags}
{renderLinkTags}
{renderScriptTags}
</>
);
}, [rankMathHead]);
fetchMetaData();
}, [postId]);
return (
<Helmet>
{!shouldFallback && rankMathNodes}
{/* SEO standard */}
<title>{metaData.title}</title>
<meta name="description" content={metaData.description} />
{metaData.canonicalUrl && <link rel="canonical" href={metaData.canonicalUrl} />}
{shouldFallback && (
<>
<title>{defaultTitle}</title>
<meta name="description" content={defaultDescription} />
<meta name="robots" content={DEFAULT_ROBOTS} />
{resolvedCanonical && (
<link rel="canonical" href={resolvedCanonical} />
)}
{/* Open Graph (Facebook, LinkedIn) */}
<meta property="og:type" content="website" />
<meta property="og:title" content={metaData.ogTitle} />
<meta property="og:description" content={metaData.ogDescription} />
<meta property="og:image" content={metaData.ogImage} />
<meta property="og:url" content={metaData.canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:title" content={defaultTitle} />
<meta property="og:description" content={defaultDescription} />
<meta property="og:image" content={defaultOgImage} />
{resolvedCanonical && (
<meta property="og:url" content={resolvedCanonical} />
)}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={defaultTitle} />
<meta name="twitter:description" content={defaultDescription} />
<meta name="twitter:image" content={defaultOgImage} />
</>
)}
{/* Twitter Cards */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metaData.ogTitle} />
<meta name="twitter:description" content={metaData.ogDescription} />
<meta name="twitter:image" content={metaData.ogImage} />
</Helmet>
);
};
+138 -267
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, isValidElement, useEffect } from "react";
import React, { useState } from "react";
import {
Box,
Typography,
@@ -9,123 +9,32 @@ import {
Modal,
Fade,
Backdrop,
CircularProgress,
} from "@mui/material";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import PropTypes from "prop-types";
import { Helmet } from "react-helmet-async";
import {
extractFirstImageSrc,
removeImageTags,
stripHtml,
} from "../utils/htmlHelpers";
const DEFAULT_CARD_IMAGE = "https://picsum.photos/id/43/800/400.webp";
const ServicePageTemplate = ({
title,
subtitle,
interestTitle,
description,
desirTitle,
features,
image,
heroBackground,
ctaText,
ctaLink,
carouselItems,
seo, // Ajout de l'objet SEO
}) => {
const [openModal, setOpenModal] = useState(false);
const [modalContent, setModalContent] = useState({});
const [loadingImage, setLoadingImage] = useState(true);
const [heroLoaded, setHeroLoaded] = useState(false);
const renderRichText = (value) => {
if (value === null || value === undefined) return null;
if (isValidElement(value)) return value;
if (typeof value === "string") {
return <span dangerouslySetInnerHTML={{ __html: value }} />;
}
return null;
};
const descriptionIsElement = isValidElement(description);
const rawDescriptionHtml =
!descriptionIsElement && typeof description === "string" ? description : "";
const descriptionMedia = useMemo(() => {
if (!rawDescriptionHtml) {
return { html: rawDescriptionHtml, media: null };
}
if (typeof window === "undefined" || !window.DOMParser) {
return { html: rawDescriptionHtml, media: null };
}
const parser = new window.DOMParser();
const doc = parser.parseFromString(rawDescriptionHtml, "text/html");
const firstImage = doc.body.querySelector("img");
if (firstImage) {
const media = {
src: firstImage.getAttribute("src"),
alt: firstImage.getAttribute("alt") || "",
};
firstImage.remove();
return { html: doc.body.innerHTML, media };
}
return { html: rawDescriptionHtml, media: null };
}, [rawDescriptionHtml]);
useEffect(() => {
if (!image) {
setHeroLoaded(true);
return;
}
setHeroLoaded(false);
const img = new Image();
img.src = image;
img.onload = () => setHeroLoaded(true);
img.onerror = () => setHeroLoaded(true);
return () => {
img.onload = null;
img.onerror = null;
};
}, [image]);
const defaultGradient = "linear-gradient(135deg, #0a1930 0%, #020710 85%)";
const activeGradient = heroBackground || defaultGradient;
const heroBackgroundStyle =
heroLoaded && image
? `url(${image}), ${activeGradient}`
: activeGradient;
const buildModalPayload = (feature) => {
const rawHtml = feature.modalText || feature.description || "";
const sanitizedHtml = removeImageTags(rawHtml);
const extractedImage = extractFirstImageSrc(rawHtml);
const dynamicImage =
extractedImage || feature.modalImage || DEFAULT_CARD_IMAGE;
return {
title: feature.title,
text: sanitizedHtml || "Texte non disponible.",
image: dynamicImage,
};
};
const getPreviewText = (feature) => {
const rawPreview = feature.description || feature.modalText || "";
const textOnly = stripHtml(removeImageTags(rawPreview));
if (!textOnly) return "Contenu indisponible.";
return textOnly.length > 140 ? `${textOnly.substring(0, 140)}` : textOnly;
};
const handleCardClick = (feature) => {
setModalContent(buildModalPayload(feature));
setLoadingImage(true);
setModalContent({
title: feature.title,
text: feature.modalText || "Texte non disponible.",
image: feature.modalImage || "https://picsum.photos/id/43/800/400.webp", // Image par défaut
});
setOpenModal(true);
};
@@ -141,64 +50,41 @@ const ServicePageTemplate = ({
fontFamily: "Arial, sans-serif",
}}
>
{/* ✅ SEO META TAGS */}
<Helmet>
<title>{seo?.metaTitle || title}</title>
<meta name="description" content={seo?.metaDescription || ""} />
<meta name="keywords" content={seo?.keywords || ""} />
<meta property="og:title" content={seo?.metaTitle || title} />
<meta property="og:description" content={seo?.metaDescription || ""} />
<meta property="og:image" content={seo?.ogImage || image} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={seo?.metaTitle || title} />
<meta name="twitter:description" content={seo?.metaDescription || ""} />
<meta name="twitter:image" content={seo?.ogImage || image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* Section Hero */}
{/* Section Attention */}
<Box
sx={{
mt: 5,
backgroundImage: heroBackgroundStyle,
backgroundSize: heroLoaded && image ? "cover, cover" : "cover",
backgroundPosition: heroLoaded && image ? "center, center" : "center",
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
minHeight: "70vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
color: "black",
color: "white",
padding: "20px",
backgroundColor: heroLoaded ? undefined : "#020712",
transition: "background-image 0.8s ease, opacity 0.4s ease",
}}
>
<Box
sx={{
maxWidth: "800px",
backgroundColor: "rgba(255, 255, 255, 0.39)",
p: 5,
borderRadius: 5,
}}
>
<Box sx={{ maxWidth: "800px" }}>
<Typography
variant="h1"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: {
xs: "2rem",
md: "3rem",
lg: "3rem",
whiteSpace: "pre-line",
},
fontSize: { xs: '2rem', md: '3rem', lg: '4rem' }, // Responsive
}}
>
{title}
</Typography>
<Typography variant="body1" component="div" sx={{ mb: 4 }}>
{renderRichText(subtitle) ?? subtitle ?? ""}
<Typography
variant="body1"
sx={{
mb: 4
}}
>
{subtitle}
</Typography>
<Button
href={ctaLink}
@@ -208,7 +94,7 @@ const ServicePageTemplate = ({
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: "#0e467f",
backgroundColor: " #0e467f",
"&:hover": { backgroundColor: "#00bcd4" },
}}
>
@@ -217,99 +103,69 @@ const ServicePageTemplate = ({
</Box>
</Box>
{/* Section numero 1 */}
<Box sx={{ fontWeight: "5", textAlign: "center" }}>
<Typography
variant="h2"
{/* Section Interest */}
<Box sx={{ padding: "40px 20px", textAlign: "center" }}>
<Typography variant="h2"
sx={{
fontWeight: "bold",
mb: 7,
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
whiteSpace: "pre-line",
mb: 2,
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
}}
>
{interestTitle}
Pourquoi choisir notre formation ?
</Typography>
<Typography
variant="body1"
sx={{ maxWidth: "800px", margin: "0 auto", mb: 4 }}
>
{description}
</Typography>
{descriptionIsElement ? (
<Typography variant="body1" component="div">
{renderRichText(description)}
</Typography>
) : (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
alignItems: "center",
justifyContent: "center",
textAlign: { xs: "center", md: "left" },
}}
>
{descriptionMedia.media?.src && (
<Box
component="img"
src={descriptionMedia.media.src}
alt={descriptionMedia.media.alt || "Illustration"}
sx={{
width: { xs: "100%", md: "42%" },
borderRadius: 4,
objectFit: "cover",
boxShadow: "0 25px 45px rgba(0,0,0,0.25)",
mb: { xs: 2, md: 5 },
mx: { xs: "auto", md: 0 },
marginLeft: { xs: 2, md: 6 } ,
}}
/>
)}
<Typography
variant="body1"
component="div"
sx={{
flex: 1,
textAlign: { xs: "center", md: "left" },
"& p": {
margin: "0 auto",
textAlign: "left",
lineHeight: 1.65,
maxWidth: 760,
},
"& p + p": {
marginTop: 1.2,
},
}}
dangerouslySetInnerHTML={{ __html: descriptionMedia.html || "" }}
/>
</Box>
)}
</Box>
{/* Section numero 2 */}
{/* Section Desire */}
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: "2rem", md: "3rem", lg: "2rem" },
}}
sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
}}
>
{desirTitle}
Ce que nous offrons
</Typography>
<Grid container spacing={4} justifyContent="center">
{features.map((feature, index) => (
<Grid item xs={12} md={4} key={index}>
<Card
onClick={() => handleCardClick(feature)}
sx={{
cursor: "pointer",
boxShadow: 2,
borderRadius: 2,
textAlign: "center",
padding: "20px",
backgroundColor: "#f9f9f9",
transition: "0.3s",
"&:hover": { transform: "scale(1.05)", boxShadow: 5 },
cursor: "pointer",
transition: "transform 0.3s ease, box-shadow 0.3s ease",
"&:hover": {
transform: {
xs: "none", // Pas d'hover sur mobile
md: "scale(1.05)", // Hover sur desktop uniquement
},
boxShadow: {
xs: 2, // Pas d'effet de hover sur mobile
md: 5, // Augmentation de l'ombre sur desktop
},
},
"&:active": {
transform: "scale(0.98)", // Effet de clic pour le tactile
boxShadow: 3,
},
}}
onClick={() => handleCardClick(feature)}
>
<CardContent>
<Typography
@@ -318,18 +174,17 @@ const ServicePageTemplate = ({
fontWeight: "bold",
mb: 2,
fontSize: 26,
whiteSpace: "pre-line",
}}
>
{feature.title}
</Typography>
<Typography variant="body2" sx={{ color: "#555" }}>
{getPreviewText(feature)}
</Typography>
</CardContent>
</Card>
</Grid>
))}
<Typography variant="body2" sx={{ color: "#555" }}>
{feature.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
@@ -339,7 +194,9 @@ const ServicePageTemplate = ({
onClose={handleCloseModal}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{ timeout: 500 }}
BackdropProps={{
timeout: 500,
}}
>
<Fade in={openModal}>
<Box
@@ -351,39 +208,50 @@ const ServicePageTemplate = ({
width: "90%",
maxWidth: "600px",
p: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
background: "white",
background: "rgb(255, 255, 255)",
boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
backdropFilter: "blur(10px)",
borderRadius: 2,
boxShadow: 3,
border: "1px solid rgba(255, 255, 255, 0.3)",
color: "rgb(0, 0, 0)",
}}
>
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
<img
src={modalContent.image}
alt={modalContent.title}
style={{
width: "100%",
borderRadius: "8px",
display: loadingImage ? "none" : "block",
marginBottom: "20px",
}}
onLoad={() => setLoadingImage(false)}
/>
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
{modalContent.title}
</Typography>
<Typography variant="body1" sx={{ mb: 4 }} dangerouslySetInnerHTML={{ __html: modalContent.text }} >
<Typography variant="body1" sx={{ mb: 4 }}>
{modalContent.text}
</Typography>
<Button
onClick={handleCloseModal}
variant="contained"
color="secondary"
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: "#f44336",
"&:hover": { backgroundColor: "#d32f2f" },
}}
>
Retour
Quitter
</Button>
</Box>
</Fade>
</Modal>
{/* Carousel Section */}
{carouselItems && carouselItems.length > 0 && (
<Box
@@ -393,11 +261,11 @@ const ServicePageTemplate = ({
backgroundColor: "#f9f9f9",
}}
>
<Typography
variant="h2"
<Typography variant="h2"
sx={{
marginBottom: 4,
fontSize: { xs: "2rem", md: "3rem", lg: "3rem" }, // Responsive
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
}}
>
Découvrez nos réalisations
@@ -434,11 +302,10 @@ const ServicePageTemplate = ({
}}
/>
<CardContent>
<Typography
variant="h3"
<Typography variant="h3"
sx={{
fontWeight: "bold",
fontSize: { xs: "1.2rem", md: "2rem", lg: "2.5rem" }, // Responsive
fontSize: { xs: '1.2rem', md: '2rem', lg: '2.5rem' }, // Responsive
}}
>
{item.title}
@@ -451,44 +318,48 @@ const ServicePageTemplate = ({
</Swiper>
</Box>
)}
{/* Section Action */}
<Box
sx={{
backgroundColor: "#0e467f",
color: "white",
textAlign: "center",
padding: "40px 20px",
}}
>
<Typography variant="h2"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
}}
>
Prêt à démarrer ?
</Typography>
<Typography variant="body1" sx={{ mb: 4 }}>
Rejoignez-nous dès aujourdhui et profitez de nos services pour
booster votre activité.
</Typography>
<Button
href={ctaLink}
variant="contained"
color="secondary"
size="large"
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: "#00bcd4",
"&:hover": { backgroundColor: "#0288d1" },
}}
>
{ctaText}
</Button>
</Box>
</Box>
);
};
// Définition des types des props avec PropTypes
ServicePageTemplate.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
interestTitle: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
desirTitle: PropTypes.string.isRequired,
features: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
modalText: PropTypes.string,
modalImage: PropTypes.string,
})
).isRequired,
image: PropTypes.string,
ctaText: PropTypes.string.isRequired,
ctaLink: PropTypes.string.isRequired,
carouselItems: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
})
),
heroBackground: PropTypes.string,
seo: PropTypes.object, // Ajout du SEO en option
};
// Valeurs par défaut
ServicePageTemplate.defaultProps = {
carouselItems: [],
heroBackground: "linear-gradient(135deg, #0a1930 0%, #020710 85%)",
seo: {}, // SEO par défaut vide
};
export default ServicePageTemplate;
+61 -67
View File
@@ -11,79 +11,73 @@ const TestimonialSection = () => {
padding: "50px 20px",
}}
>
{/* Image à gauche */}
<Grid item xs={12} md={6}>
<Box
component="img"
src="https://picsum.photos/600/600.webp"
alt="Bureau d'études & Maîtrise d'oeuvre"
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: { xs: "15px 15px 0 0", md: "15px 0 0 15px" },
}}
/>
</Grid>
{/* Contenu Témoignage */}
<Grid
item
xs={12}
md={6}
sx={{
background: "linear-gradient(to right, #002F6C, #0E467F)",
color: "white",
padding: "40px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<Typography
variant="h3"
{/* Image à gauche */}
<Grid item xs={12} md={6}>
<Box
component="img"
src="https://picsum.photos/600/600.webp"
alt="Illustration"
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: { xs: "15px 15px 0 0", md: "15px 0 0 15px" },
}}
/>
</Grid>
{/* Contenu Témoignage */}
<Grid
item
xs={12}
md={6}
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, // Responsive
fontWeight: "bold",
mb: 2,
fontStyle: "italic",
position: "relative",
"&::before": {
content: '"“"',
fontSize: "50px",
position: "absolute",
top: "-10px",
left: "-5px",
opacity: 0.3,
},
background: "linear-gradient(to right, #002F6C, #0E467F)",
color: "white",
padding: "40px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
In3 a transformé notre vision en réalité avec une approche passionnée
et professionnelle.
</Typography>
<Typography
variant="h4"
sx={{
fontWeight: "bold",
mb: 2,
fontStyle: "italic",
position: "relative",
"&::before": {
content: '"“"',
fontSize: "50px",
position: "absolute",
top: "-10px",
left: "-5px",
opacity: 0.3,
},
}}
>
In3 a transformé notre vision en réalité avec une approche
passionnée et professionnelle.
</Typography>
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
<Avatar
alt="client bureau d'étude be-in3 le Mans(72)"
src="https://picsum.photos/100.webp"
sx={{ width: 56, height: 56, marginRight: 2 }}
/>
<Box>
<Typography
variant="span"
sx={{
fontWeight: "bold",
}}
>
Marie Dupont
</Typography>
<Typography variant="body2">
Directrice Créative chez TechVision
</Typography>
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
<Avatar
alt="Marie Dupont"
src="https://picsum.photos/100.webp"
sx={{ width: 56, height: 56, marginRight: 2 }}
/>
<Box>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Marie Dupont
</Typography>
<Typography variant="body2">
Directrice Créative chez TechVision
</Typography>
</Box>
</Box>
</Box>
</Grid>
</Grid>
</Box>
);
};
@@ -1,119 +0,0 @@
import { Box } from "@mui/material";
import { useEffect, useRef } from "react";
import Quill from "quill";
import "quill/dist/quill.snow.css";
const DEFAULT_TOOLBAR = [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ color: [] }, { background: [] }],
[{ list: "ordered" }, { list: "bullet" }],
["link", "blockquote", "code-block"],
["clean"],
];
const RichTextEditor = ({
value = "",
onChange,
placeholder = "Commencez à écrire…",
minHeight = 200,
toolbarOptions = DEFAULT_TOOLBAR,
sx = {},
}) => {
const containerRef = useRef(null);
const quillRef = useRef(null);
const internalUpdateRef = useRef(false);
const latestOnChangeRef = useRef(onChange);
useEffect(() => {
latestOnChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
if (!containerRef.current || quillRef.current) {
return;
}
const quill = new Quill(containerRef.current, {
theme: "snow",
placeholder,
modules: {
toolbar: toolbarOptions,
},
});
quillRef.current = quill;
const handleTextChange = () => {
if (internalUpdateRef.current) {
return;
}
const html = quill.root.innerHTML;
if (typeof latestOnChangeRef.current === "function") {
latestOnChangeRef.current(html);
}
};
quill.on("text-change", handleTextChange);
if (value) {
internalUpdateRef.current = true;
quill.clipboard.dangerouslyPasteHTML(value);
internalUpdateRef.current = false;
}
return () => {
quill.off("text-change", handleTextChange);
const toolbar = containerRef.current
? containerRef.current.previousSibling
: null;
if (toolbar && toolbar.classList?.contains("ql-toolbar")) {
toolbar.remove();
}
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
quillRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [placeholder]); // initialise une fois (placeholder rarement modifié)
useEffect(() => {
const quill = quillRef.current;
if (!quill) {
return;
}
const normalizedValue = value || "";
if (normalizedValue === quill.root.innerHTML) {
return;
}
internalUpdateRef.current = true;
quill.setContents(quill.clipboard.convert(normalizedValue));
internalUpdateRef.current = false;
}, [value]);
return (
<Box
sx={{
borderRadius: 1,
overflow: "hidden",
"& .ql-container": {
borderColor: "rgba(0,0,0,0.12)",
minHeight,
},
"& .ql-toolbar": {
borderColor: "rgba(0,0,0,0.12)",
},
"& .ql-editor": {
minHeight,
},
...sx,
}}
>
<div ref={containerRef} />
</Box>
);
};
export default RichTextEditor;
-5
View File
@@ -1,5 +0,0 @@
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);
+14 -55
View File
@@ -6,42 +6,24 @@ import theme from "./theme";
import { HelmetProvider } from "react-helmet-async";
import SimpleReactLightbox from "simple-react-lightbox";
import NotFound from "./components/Pages/NotFound.jsx";
import GestionArticles from "./components/Pages/GestionArticles";
import EditPost from "./components/EditPost";
import GestionPageAccueil from "./components/Pages/GestionPageAccueil";
import GestionPagesACF from "./components/GestionPagesACF";
import GestionBureauEtudeACF from "./components/GestionBureauEtudeACF";
import EditPageACF from "./components/Pages/EditPageACF";
import Tools from "./components/Pages/Tools";
// Ajoutez les autres pages Admin
import GestionCours from "./components/Admin/GestionCours";
import ListeCours from "./components/Admin/ListeCours";
import CoursLecture from './components/Admin/CoursLecture';
// Lazy loading des pages
const Home = lazy(() => import("./components/Pages/Home.jsx"));
const About = lazy(() => import("./components/Pages/About"));
const Contact = lazy(() => import("./components/Pages/Contact"));
const Post = lazy(() => import("./components/Pages/Post"));
const PostDetails = lazy(() => import("./components/PostDetails"));
const Search = lazy(() => import("./components/Pages/Search")); // Nouveau composant pour la recherche
const BureauEtude = lazy(() => import("./components/Pages/BureauEtude"));
const CreatePost = lazy(() => import("./components/Pages/CreatePost"));
const PostList = lazy(() => import("./components/Pages/PostList"));
const Login = lazy(() => import("./components/Pages/Login"));
const Dashboard = lazy(() => import("./components/Pages/Dashboard"));
const Home = lazy(() => import('./components/Pages/Home.jsx'));
const About = lazy(() => import('./components/Pages/About'));
const Contact = lazy(() => import('./components/Pages/Contact'));
const Post = lazy(() => import('./components/Pages/Post'));
const PostDetails = lazy(() => import('./components/PostDetails')) ;
const Search = lazy(() => import('./components/Pages/Search')); // Nouveau composant pour la recherche
const BureauEtude = lazy(() => import('./components/Pages/BureauEtude'));
// Import des services
import ServiceUn from "./components/Pages/ServiceUn.jsx";
import ServiceDeux from "./components/Pages/ServiceDeux.jsx";
import ServiceTrois from "./components/Pages/ServiceTrois.jsx";
import ServiceQuatre from "./components/Pages/ServiceQuatre.jsx";
import ServiceCinq from "./components/Pages/ServiceCinq.jsx";
// Import des composants globaux
import Header from "./components/Header";
import Footer from "./components/Footer";
import Footer from './components/Footer';
function YourApp() {
return (
@@ -51,39 +33,16 @@ function YourApp() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/posts" element={<Post />} />
<Route path="/admin/edit-post/:id" element={<EditPost />} />
<Route path="/nosFormations" element={<BureauEtude />} />
<Route path="/post/:id" element={<PostDetails />} />
<Route path="/bureauEtude" element={<BureauEtude />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/search" element={<Search />} />{" "}
{/* Route pour les recherches */}
<Route path="/search" element={<Search />} /> {/* Route pour les recherches */}
{/* Routes pour les services */}
<Route
path="/services/prestation-maitrise-oeuvre"
element={<ServiceUn />}
/>
<Route
path="/services/formations-web"
element={<ServiceDeux />}
/>
<Route path="/services/electricite" element={<ServiceTrois />} />
<Route path="/services/service-securite-incendie" element={<ServiceQuatre />} />
<Route path="/services/expertises-tce" element={<ServiceCinq />} />
<Route path="/admin/create-post" element={<CreatePost />} />
<Route path="/post/:slug" element={<PostDetails />} />
<Route path="/admin/posts" element={<PostList />} />
<Route path="/admin/login" element={<Login />} />
<Route path="/admin/dashboard" element={<Dashboard />} />
<Route path="/admin/gestion-articles" element={<GestionArticles />} />
<Route path="/admin/gestion-page-accueil" element={<GestionPageAccueil />} />
<Route path="/services/prestation-maitrise-oeuvre" element={<ServiceUn />} />
<Route path="/services/structure-beton-charpente-metallique-bois" element={<ServiceDeux />} />
<Route path="/services/formation-video" element={<ServiceTrois />} />
<Route path="*" element={<NotFound />} />
<Route path="/admin/pages-acf" element={<GestionPagesACF />} />
<Route path="/admin/bureau-etude-acf" element={<GestionBureauEtudeACF />} />
<Route path="/admin/edit-page/:id" element={<EditPageACF />} />
<Route path="/admin/gestion-cours" element={<GestionCours />} />
<Route path="/admin/liste-cours" element={<ListeCours />} />
<Route path="/cours/lecture/:token" element={<CoursLecture />} />
<Route path="/admin/tools" element={<Tools />} />
</Routes>
<Footer />
</Router>
-193
View File
@@ -1,193 +0,0 @@
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;
-48
View File
@@ -1,48 +0,0 @@
const hasDomParser =
(typeof window !== "undefined" && typeof window.DOMParser !== "undefined") ||
typeof DOMParser !== "undefined";
const createDocument = (html) => {
if (!hasDomParser || !html) return null;
const ParserConstructor =
typeof DOMParser !== "undefined"
? DOMParser
: typeof window !== "undefined" && window.DOMParser
? window.DOMParser
: null;
if (!ParserConstructor) {
return null;
}
const parser = new ParserConstructor();
return parser.parseFromString(html, "text/html");
};
export const extractFirstImageSrc = (html) => {
if (!html) return "";
const doc = createDocument(html);
if (doc) {
const img = doc.querySelector("img");
if (img) {
return img.getAttribute("src") || "";
}
}
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
return match?.[1] || "";
};
export const removeImageTags = (html) => {
if (!html) return "";
return html.replace(/<img\b[^>]*>/gi, "");
};
export const stripHtml = (html) => {
if (!html) return "";
const doc = createDocument(html);
if (doc) {
return doc.body.textContent || "";
}
return html.replace(/<[^>]*>/g, "");
};
+26 -331
View File
@@ -1,8 +1,7 @@
import axios from "axios";
import { getToken } from "./auth"; // 🔥 Import du token pour l'authentification
import { getToken } from "./auth"; // 🔥 Import du token depuis auth.js
const API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2";
const RANKMATH_API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json/rankmath/v1";
/**
* 🔹 Upload une image et retourne son ID
@@ -10,9 +9,9 @@ const RANKMATH_API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp
* @returns {Promise<number>} - L'ID de l'image uploadée
*/
export async function uploadImage(file) {
const token = getToken();
const token = getToken(); // 🔥 Récupération automatique du token
if (!token) {
console.error("❌ Aucun token trouvé !");
console.error("❌ Aucun token trouvé ! L'utilisateur doit se reconnecter.");
throw new Error("Utilisateur non authentifié.");
}
@@ -20,63 +19,27 @@ export async function uploadImage(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("title", file.name);
formData.append("title", "Nouvelle image");
formData.append("status", "publish");
try {
const response = await axios.post(`${API_URL}/media`, formData, {
headers: {
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
"Content-Type": "multipart/form-data"
const response = await axios.post(
`${API_URL}/media`,
formData,
{
headers: {
"Authorization": `Basic ${token}`, // 🔥 Authentification Basic
}
}
});
);
console.log("✅ Image uploadée avec succès :", response.data);
return response.data.id; // Retourne l'ID de l'image
return response.data.id; // Retourne l'ID de l'image
} catch (error) {
console.error("❌ Erreur d'upload :", error.response?.data || error.message);
console.error("❌ Erreur d'upload :", error.response ? error.response.data : error.message);
throw error;
}
}
export async function uploadImageFromUrl(imageUrl) {
const token = getToken();
if (!token) {
console.error("❌ Aucun token trouvé !");
throw new Error("Utilisateur non authentifié.");
}
try {
// On télécharge limage sous forme de blob
const imageBlob = await fetch(imageUrl).then(res => res.blob());
const file = new File([imageBlob], "image-cloudinary.webp", { type: "image/webp" });
const formData = new FormData();
formData.append("file", file);
formData.append("title", "Image importée");
formData.append("status", "publish");
const response = await axios.post(`${API_URL}/media`, formData, {
headers: {
"Authorization": `Basic ${token}`,
"Content-Type": "multipart/form-data"
}
});
console.log("✅ Image uploadée depuis Cloudinary :", response.data);
return response.data.id;
} catch (error) {
console.error("❌ Erreur upload depuis Cloudinary :", error.response?.data || error.message);
throw error;
}
}
export async function fetchPosts() {
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/posts");
if (!response.ok) throw new Error("Erreur lors de la récupération des articles");
return await response.json();
}
/**
* 🔹 Crée un post WordPress avec une image mise en avant
@@ -86,301 +49,33 @@ export async function fetchPosts() {
* @returns {Promise<Object>} - Le post créé
*/
export async function createPost(title, content, imageId) {
const token = getToken();
if (!token) throw new Error("Utilisateur non authentifié.");
const response = await axios.post(
`${API_URL}/posts`,
{
title,
content,
status: "publish",
featured_media: imageId, // 🔥 très important
},
{
headers: {
"Authorization": `Basic ${token}`,
"Content-Type": "application/json",
},
}
);
return response.data;
}
/**
* 🔹 Récupère un article WordPress par son ID
* @param {number} postId - L'ID du post à récupérer
* @returns {Promise<Object>} - Données du post récupéré
*/
export async function getPostById(postId) {
try {
const response = await axios.get(`${API_URL}/posts/${postId}`);
console.log("✅ Article récupéré :", response.data);
return response.data;
} catch (error) {
console.error("❌ Erreur récupération article :", error.response?.data || error.message);
throw error;
}
}
/**
* 🔹 Met à jour un article WordPress
* @param {number} postId - L'ID de l'article à modifier
* @param {string} title - Le nouveau titre
* @param {string} content - Le nouveau contenu
* @returns {Promise<Object>} - L'article mis à jour
*/
export async function updatePost(postId, title, content, imageId = null) {
const token = getToken();
if (!token) {
console.error("❌ Aucun token trouvé !");
throw new Error("Utilisateur non authentifié.");
}
const dataToSend = {
title,
content,
status: "publish",
};
if (imageId) {
dataToSend.featured_media = imageId; // 🔥 Ajoute l'image en vedette
}
try {
const response = await axios.post(`${API_URL}/posts/${postId}`, dataToSend, {
headers: {
Authorization: `Basic ${token}`,
"Content-Type": "application/json",
},
});
console.log("✅ Article mis à jour avec succès :", response.data);
return response.data;
} catch (error) {
console.error("❌ Erreur mise à jour article :", error.response?.data || error.message);
throw error;
}
}
/**
* 🔹 Supprime un article WordPress et son image associée
* @param {number} postId - L'ID de l'article à supprimer
* @param {number} imageId - L'ID de l'image en vedette à supprimer (facultatif)
* @returns {Promise<void>}
*/
export async function deletePost(postId) {
const token = getToken();
if (!token) throw new Error("Utilisateur non authentifié.");
try {
console.log(`📢 Suppression article ID: ${postId}...`);
// 🔍 Récupérer les infos de l'article pour connaître l'image
const postRes = await axios.get(`${API_URL}/posts/${postId}`, {
headers: {
Authorization: `Basic ${token}`,
},
});
const imageId = postRes.data.featured_media;
// ✅ Supprimer l'article
await axios.delete(`${API_URL}/posts/${postId}?force=true`, {
headers: {
Authorization: `Basic ${token}`,
},
});
console.log(`✅ Article ${postId} supprimé.`);
// 🧹 Supprimer l'image si elle existe
if (imageId) {
await axios.delete(`${API_URL}/media/${imageId}?force=true`, {
headers: {
Authorization: `Basic ${token}`,
},
});
console.log(`🗑️ Image ${imageId} supprimée.`);
}
} catch (error) {
console.error("❌ Erreur suppression article ou image :", error.response?.data || error.message);
throw error;
}
}
/**
* 🔹 Met à jour les champs ACF d'une page WordPress
* @param {number} pageId - L'ID de la page à modifier
* @param {object} acfData - Les données des champs ACF à mettre à jour
*/
export async function updateHomePageACF(pageId, newData) {
const token = getToken();
const token = getToken(); // 🔥 Récupération automatique du token
if (!token) {
console.error("❌ Aucun token trouvé !");
console.error("❌ Aucun token trouvé ! L'utilisateur doit se reconnecter.");
throw new Error("Utilisateur non authentifié.");
}
try {
console.log(`📢 Tentative de mise à jour des ACF pour la page ${pageId}...`, newData);
const response = await fetch(
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${pageId}`,
{
method: "PUT",
headers: {
"Authorization": `Basic ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ acf: newData }),
}
);
const responseData = await response.json();
if (!response.ok) {
throw new Error(`Erreur API: ${JSON.stringify(responseData)}`);
}
console.log("✅ Mise à jour réussie :", responseData);
return responseData;
} catch (error) {
console.error("❌ Erreur mise à jour ACF :", error);
throw error;
}
}
/**
* 🔹 Récupère une page WordPress par son ID avec ses champs ACF
* @param {number} pageId - L'ID de la page à récupérer
* @returns {Promise<Object>} - Données de la page avec ACF
*/
export async function getPageById(pageId) {
if (!pageId) {
console.error("❌ Erreur : l'ID de la page est `undefined` !");
return null;
}
try {
const response = await axios.get(
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${pageId}?_fields=id,title,link,acf,_rank_math_seo_meta`
);
return response.data;
} catch (error) {
console.error("❌ Erreur récupération page :", error);
return null;
}
}
/**
* 🔹 Met à jour les champs ACF d'une page WordPress
* @param {number} pageId - L'ID de la page à modifier
* @param {object} acfData - Les nouvelles données ACF à mettre à jour
*/
export async function updatePageACF(pageId, acfData) {
const token = getToken();
if (!token) {
console.error("❌ Aucun token trouvé !");
throw new Error("Utilisateur non authentifié.");
}
try {
console.log(`📢 Mise à jour des ACF pour la page ${pageId}...`, acfData);
const response = await axios.post(
`${API_URL}/pages/${pageId}`,
{ acf: acfData }, // Envoi uniquement les champs ACF
{
headers: {
"Authorization": `Basic ${token}`,
"Content-Type": "application/json",
},
}
);
console.log("✅ Mise à jour ACF réussie :", response.data);
return response.data;
} catch (error) {
console.error("❌ Erreur mise à jour ACF :", error.response?.data || error.message);
throw error;
}
}
/**
* 🔹 Récupère la liste des pages WordPress ayant des champs ACF
* @returns {Promise<Array>} - Liste des pages
*/
export async function fetchPages() {
try {
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages?_fields=id,title");
if (!response.ok) {
throw new Error("❌ Erreur lors de la récupération des pages WordPress.");
}
const data = await response.json();
if (!data || typeof data !== "object" || !Array.isArray(data)) {
console.error("❌ Données invalides reçues :", data);
return []; // ✅ Retourne un tableau vide pour éviter toute erreur
}
return data;
} catch (error) {
console.error("❌ Erreur `fetchPages()` :", error);
return []; // ✅ Retourne un tableau vide en cas d'erreur pour éviter le plantage
}
}
/**
* 🔹 Met à jour les métadonnées Rank Math (SEO) d'une page/post.
* @param {number} objectId - ID WordPress de la page.
* @param {object} meta - Objet des champs Rank Math à modifier.
*/
export async function updateRankMathMeta(objectId, meta = {}) {
const token = getToken();
if (!token) {
console.error("❌ Aucun token Rank Math trouvé !");
throw new Error("Utilisateur non authentifié.");
}
const sanitizedMeta = Object.entries(meta).reduce((acc, [key, value]) => {
const cleanedValue = typeof value === "string" ? value.trim() : value;
if (cleanedValue) {
acc[key] = cleanedValue;
}
return acc;
}, {});
if (!Object.keys(sanitizedMeta).length) {
console.warn("️ Aucun champ Rank Math fourni, mise à jour ignorée.");
return null;
}
try {
const response = await axios.post(
`${RANKMATH_API_URL}/updateMeta`,
`${API_URL}/posts`,
{
objectType: "post", // ✅ Les pages WordPress sont du type "post".
objectID: objectId,
meta: sanitizedMeta,
title: title,
content: content,
status: "publish",
featured_media: imageId, // 🔥 Associer l'image mise en avant
},
{
headers: {
Authorization: `Basic ${token}`,
"Content-Type": "application/json",
},
"Authorization": `Basic ${token}`,
"Content-Type": "application/json"
}
}
);
console.log("✅ Métadonnées Rank Math mises à jour :", response.data);
console.log("✅ Post créé avec succès :", response.data);
return response.data;
} catch (error) {
console.error("❌ Erreur mise à jour Rank Math :", error.response?.data || error.message);
console.error("❌ Erreur lors de la création du post :", error.response ? error.response.data : error.message);
throw error;
}
}
-4949
View File
File diff suppressed because one or more lines are too long
+23 -21
View File
@@ -4,27 +4,25 @@ import viteCompression from 'vite-plugin-compression';
import { visualizer } from 'rollup-plugin-visualizer';
import path from 'path';
export default defineConfig({
plugins: [
// Plugin React
// Plugin React
react(),
// ✅ Compression Brotli & Gzip fusionnées
// Compression Brotli
viteCompression({
algorithm: 'brotliCompress', // Utilisation de Brotli en priorité
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240, // Compression à partir de 10 Ko
threshold: 10240, // Fichiers supérieurs à 10 Ko seront compressés
}),
// Compression Gzip
viteCompression({
algorithm: 'gzip', // Compression Gzip en fallback
algorithm: 'gzip',
ext: '.gz',
threshold: 10240,
}),
// ✅ Visualisation des bundles (ouvre `stats.html` après le build)
// Visualiseur de bundle
visualizer({
open: false, // Mets `true` si tu veux ouvrir le fichier automatiquement
open: true, // Ouvre automatiquement le fichier généré après le build
filename: 'stats.html',
template: 'treemap',
}),
@@ -32,6 +30,7 @@ export default defineConfig({
resolve: {
alias: {
// Définit les alias pour des chemins simplifiés
'@pages': path.resolve(__dirname, './src/pages'),
'@components': path.resolve(__dirname, './src/components'),
'@assets': path.resolve(__dirname, './src/assets'),
@@ -40,17 +39,18 @@ export default defineConfig({
build: {
chunkSizeWarningLimit: 1000,
target: 'esnext', // ✅ Meilleure compatibilité avec les navigateurs modernes
minify: 'esbuild', // ✅ Utilisation d'Esbuild pour un build rapide
sourcemap: true, // ✅ Active les source maps en production (utile pour le debug)
target: 'esnext', // Cible des navigateurs modernes
minify: 'esbuild', // Minification avec esbuild pour une meilleure performance
sourcemap: false, // Désactive les sourcemaps en production (activez pour dev si nécessaire)
rollupOptions: {
output: {
manualChunks(id) {
// Création de chunks manuels pour optimiser les performances
if (id.includes('node_modules')) {
if (id.includes('@mui')) {
return 'vendor_mui'; // ✅ Sépare MUI du reste des dépendances
return 'vendor_mui'; // Chunk spécifique à MUI
}
return 'vendor'; // ✅ Sépare les dépendances globales
return 'vendor'; // Autres dépendances
}
},
},
@@ -58,16 +58,18 @@ export default defineConfig({
},
server: {
port: 3000, // ✅ Serveur sur le port 3000
open: true, // Ouvre le navigateur au démarrage
cors: true, // ✅ Active les requêtes CORS
port: 3000, // Définit le port du serveur de développement
open: true, // Ouvre automatiquement le navigateur au démarrage
cors: true, // Autorise toutes les requêtes CORS
},
optimizeDeps: {
include: [
'swiper/react',
'swiper/modules',
'swiper/react', // Inclut swiper/react dans les dépendances optimisées
'swiper/modules', // Inclut swiper/modules dans les dépendances optimisées
],
exclude: [
'cssnano', // Exclut les dépendances problématiques comme cssnano
],
},
});
BIN
View File
Binary file not shown.
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
#
# 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=
@@ -1,36 +0,0 @@
import express from "express";
import axios from "axios";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
router.get("/cloudinary-images", async (req, res) => {
try {
const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
const apiKey = process.env.CLOUDINARY_API_KEY;
const apiSecret = process.env.CLOUDINARY_API_SECRET;
const auth = Buffer.from(`${apiKey}:${apiSecret}`).toString("base64");
const response = await axios.get(`https://api.cloudinary.com/v1_1/${cloudName}/resources/image`, {
params: {
prefix: "articles-octopus/",
max_results: 30,
type: "upload",
},
headers: {
Authorization: `Basic ${auth}`,
},
});
console.log("✅ Données Cloudinary reçues :", response.data);
res.json(response.data.resources);
} catch (err) {
console.error("❌ Erreur Cloudinary complète :", err.response?.data || err.message);
res.status(500).json({ error: "Erreur lors de la récupération des images" });
}
});
export default router;
-123
View File
@@ -1,123 +0,0 @@
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 = `
<p><strong>Nom :</strong> ${name}</p>
<p><strong>E-mail :</strong> ${email}</p>
<p><strong>Consentement :</strong> ${consentLabel}</p>
<p><strong>Message :</strong></p>
<p>${message.replace(/\n/g, "<br />")}</p>
`;
// 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;
-39
View File
@@ -1,39 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import cors from "cors";
import cloudinaryRoute from "./cloudinaryRoute.js";
import contactRoute from "./contactRoute.js";
dotenv.config();
const app = express();
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, () => {
console.log(`✅ Serveur backend Cloudinary lancé sur http://localhost:${PORT}`);
});
-979
View File
@@ -1,979 +0,0 @@
{
"name": "cloudinary-proxy-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloudinary-proxy-server",
"version": "1.0.0",
"dependencies": {
"axios": "^1.6.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.10.tgz",
"integrity": "sha512-V7O/fFKM539IC2bweloFWuoiJ9OtI3W2uIqJPWM8IT5xxNyt73QtvVqmSpcDmk07ivmmlKB+rRY0vpQjIYNtKw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"name": "cloudinary-proxy-server",
"version": "1.0.0",
"type": "module",
"main": "main.js",
"scripts": {
"start": "node main.js"
},
"dependencies": {
"axios": "^1.6.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"nodemailer": "^6.9.11"
}
}
@@ -270,55 +270,3 @@ function custom_cors_headers() {
header("Access-Control-Allow-Headers: Content-Type, Authorization");
}
add_action('init', 'custom_cors_headers');
// 🔹 Autoriser la modification des champs ACF via l'API REST
add_action('rest_api_init', function () {
register_rest_field(
'page', // Type de post
'acf', // Champ ACF
[
'get_callback' => function ($object) {
return get_fields($object['id']); // ✅ Vérifie que l'ID est correct
},
'update_callback' => function ($value, $object) {
foreach ($value as $field_key => $field_value) {
update_field($field_key, $field_value, $object->ID); // ✅ Correction ici
}
return true;
},
'schema' => null,
]
);
});
// accordeon
function update_acf_mission($post, $request) {
if (!function_exists('update_field')) {
return;
}
$params = $request->get_json_params();
if (isset($params['mission']) && is_array($params['mission'])) {
update_field('mission', $params['mission'], $post->ID);
}
}
add_action('rest_after_insert_page', 'update_acf_mission', 10, 2);
function add_rankmath_to_rest_api($response, $post, $request) {
$seo_meta = get_post_meta($post->ID, 'rank_math_description', true);
$seo_title = get_post_meta($post->ID, 'rank_math_title', true);
if (!empty($seo_meta)) {
$response->data['seo_description'] = $seo_meta;
}
if (!empty($seo_title)) {
$response->data['seo_title'] = $seo_title;
}
return $response;
}
add_filter('rest_prepare_page', 'add_rankmath_to_rest_api', 10, 3);