Compare commits
54 Commits
ancienne-version
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff5d08ed72 | |||
| 9d8e4e6314 | |||
| ccc2e1347d | |||
| 3fabebeb85 | |||
| a264f9b430 | |||
| 1890d830e1 | |||
| 5909871dec | |||
| f242bcc5d6 | |||
| 82406fb046 | |||
| f4cd293805 | |||
| e7e5c5e3fd | |||
| 718eb8353b | |||
| a04cc47e26 | |||
| 04aa793815 | |||
| 53ae6365f4 | |||
| 58ce0af44e | |||
| 88582a89b5 | |||
| 7c01c6e81d | |||
| 51195ac398 | |||
| fb77ca86f9 | |||
| e267591ebb | |||
| 2ee6c53a98 | |||
| bfe25828fe | |||
| f43e285a9c | |||
| b02a509839 | |||
| db8984b971 | |||
| 61222e2133 | |||
| 9f5424c89b | |||
| f7e8cfe938 | |||
| 98530922ef | |||
| 77a7cda77f | |||
| 885922483b | |||
| 38bafc63f6 | |||
| 5432b19604 | |||
| 1042eecfd2 | |||
| f0e31c4639 | |||
| 94c215a253 | |||
| 26ecadd238 | |||
| a5ee040361 | |||
| f93932a038 | |||
| 55c3b64733 | |||
| 31d57e365a | |||
| b7a2831ee3 | |||
| 8094e445d4 | |||
| 7df66f13df | |||
| 038dd3498a | |||
| 4a4d7104ac | |||
| d1cee7b3ae | |||
| 20c2e3500d | |||
| 709221a0c1 | |||
| d8395f158d | |||
| 9ceb17a6f4 | |||
| b24600acd2 | |||
| 27a2ffdfb2 |
@@ -1,2 +1,30 @@
|
|||||||
# Octopus-React-Wp
|
# Octopus-React-Wp
|
||||||
|
|
||||||
|
## Formulaire de contact – mise en route
|
||||||
|
|
||||||
|
Le formulaire (`frontend/src/components/Pages/Contact.jsx`) envoie désormais une requête POST vers l’API de contact.
|
||||||
|
Suivez ces étapes pour activer l’envoi d’e-mails :
|
||||||
|
|
||||||
|
1. **Configuration backend**
|
||||||
|
- Copiez `server/cloudinary-backend/.env.example` vers `server/cloudinary-backend/.env`.
|
||||||
|
- Renseignez vos identifiants SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, etc.).
|
||||||
|
- Facultatif : ajustez `CONTACT_RECIPIENT`, `CONTACT_FROM`, ou ajoutez des domaines supplémentaires dans `CORS_ALLOWED_ORIGINS`.
|
||||||
|
|
||||||
|
2. **Installer et lancer le serveur contact**
|
||||||
|
```bash
|
||||||
|
cd server/cloudinary-backend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
Par défaut, l’API est disponible sur `http://localhost:3001/api/contact`.
|
||||||
|
|
||||||
|
3. **Configuration du front-end**
|
||||||
|
- Copiez `frontend/.env.local.example` vers `frontend/.env.local`.
|
||||||
|
- Ajustez `VITE_CONTACT_API_URL` si nécessaire (par défaut `http://localhost:3001/api/contact` en dev).
|
||||||
|
- Relancez l’application React :
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
En production, assurez-vous que l’URL définie dans `VITE_CONTACT_API_URL` pointe vers votre API accessible publiquement (ex. `https://octopusdesign.fr/api/contact`) et que le serveur backend est déployé avec les mêmes variables d’environnement SMTP.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# URL of the contact API (backend server).
|
||||||
|
# In development, point to the local Express server.
|
||||||
|
VITE_CONTACT_API_URL=http://localhost:3001/api/contact
|
||||||
@@ -11,6 +11,8 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -6,3 +6,20 @@ Currently, two official plugins are available:
|
|||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Configuration Moodle
|
||||||
|
|
||||||
|
Les écrans d'administration (liste de cours, gestion des chapitres et lecture sécurisée) consomment l'API REST de Moodle.
|
||||||
|
Avant de lancer le projet :
|
||||||
|
|
||||||
|
1. Copiez `frontend/.env.example` vers `frontend/.env.local`.
|
||||||
|
2. Renseignez vos propres valeurs pour :
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_MOODLE_API_URL=https://votre-instance/webservice/rest/server.php
|
||||||
|
VITE_MOODLE_TOKEN=token_personnel
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ne versionnez jamais votre token : `.env` est ignoré par Git.
|
||||||
|
|
||||||
|
Ensuite vous pouvez lancer l'application avec `npm install && npm run dev` depuis le dossier `frontend`.
|
||||||
|
|||||||
+88
-13
@@ -1,26 +1,101 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>bureau d’études Maîtrise d’oeuvre</title>
|
<title data-rh="true">Centre de formations - metier du numérique</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<!-- ✅ Ajout Open Graph statique -->
|
<!-- ✅ Ajout Open Graph statique -->
|
||||||
<meta property="og:title" content="Titre par défaut - Bureau d’études">
|
<meta
|
||||||
<meta property="og:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
|
data-rh="true"
|
||||||
<meta property="og:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
|
property="og:title"
|
||||||
<meta property="og:type" content="website">
|
content="Centre de formations web IA"
|
||||||
<meta property="og:url" content="https://ton-site.com">
|
/>
|
||||||
|
<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" />
|
||||||
|
|
||||||
<!-- ✅ Twitter Cards -->
|
<!-- ✅ Twitter Cards -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta data-rh="true" name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="Titre par défaut - Bureau d’études">
|
<meta
|
||||||
<meta name="twitter:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
|
data-rh="true"
|
||||||
<meta name="twitter:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
|
name="twitter:title"
|
||||||
<link rel="preload" href="/src/assets/vendor-BFTbvl5C.js" as="script">
|
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>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<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'appuie sur React et nécessite JavaScript pour l'expérience complète.
|
||||||
|
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
|
||||||
|
utilisez ces liens directs :
|
||||||
|
</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>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1243
-297
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/update-noscript.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/prerender.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"prerender": "node scripts/prerender.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -15,10 +18,14 @@
|
|||||||
"@mui/icons-material": "^6.3.0",
|
"@mui/icons-material": "^6.3.0",
|
||||||
"@mui/material": "^6.3.0",
|
"@mui/material": "^6.3.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"cloudinary": "^2.7.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"react-simple-lightbox": "^1.0.26",
|
"react-simple-lightbox": "^1.0.26",
|
||||||
"react-toastify": "^11.0.3",
|
"react-toastify": "^11.0.3",
|
||||||
@@ -38,6 +45,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
"puppeteer": "^24.28.0",
|
||||||
"rollup-plugin-visualizer": "^5.14.0",
|
"rollup-plugin-visualizer": "^5.14.0",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vite-plugin-compression": "^0.5.1"
|
"vite-plugin-compression": "^0.5.1"
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /static/
|
Disallow: /
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://octopusdesign.fr/sitemap.xml
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
@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
|
||||||
|
} */
|
||||||
|
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
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
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
|
||||||
Grid,
|
Grid,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
const ConstructSection = ({
|
const ConstructSection = ({
|
||||||
constructTitle,
|
constructTitle,
|
||||||
@@ -17,26 +21,59 @@ const ConstructSection = ({
|
|||||||
missionTitle,
|
missionTitle,
|
||||||
missionItems,
|
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 (
|
return (
|
||||||
<Box
|
<Box sx={{ py: 6, px: 4, backgroundColor: "#f9f9f9" }}>
|
||||||
sx={{
|
|
||||||
py: 6,
|
|
||||||
px: 4,
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box>
|
<Box>
|
||||||
{/* Titre et sous-titre */}
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="h2"
|
variant="h2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fontSize: { xs: "2rem", md: "2.4rem", lg: "3rem" },
|
fontSize: { xs: "2rem", md: "2.4rem", lg: "3rem" },
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{constructTitle}{" "}
|
{constructTitle}{" "}
|
||||||
@@ -44,7 +81,6 @@ const ConstructSection = ({
|
|||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
|
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
|
||||||
|
|
||||||
color: "#0e467f",
|
color: "#0e467f",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
@@ -53,14 +89,9 @@ const ConstructSection = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Secteurs */}
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
sx={{
|
sx={{ mt: 2, fontWeight: "bold", color: "#0e467f" }}
|
||||||
mt: 2,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#0e467f",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Secteurs : {constructSector}
|
Secteurs : {constructSector}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -76,13 +107,12 @@ const ConstructSection = ({
|
|||||||
dangerouslySetInnerHTML={{ __html: constructText }}
|
dangerouslySetInnerHTML={{ __html: constructText }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cadre résumé */}
|
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
mt: 4,
|
mt: 4,
|
||||||
backgroundColor: "#0e467f",
|
backgroundColor: "#0e467f",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px 0 12px 12px",
|
||||||
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
|
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -105,36 +135,66 @@ const ConstructSection = ({
|
|||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: "12px",
|
|
||||||
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
{/* ✅ Correction de la duplication du titre */}
|
||||||
variant="h3"
|
{missionTitle && (
|
||||||
sx={{
|
<Typography
|
||||||
fontWeight: "bold",
|
component="h3"
|
||||||
textAlign: "center",
|
variant="h4"
|
||||||
mt: 3,
|
sx={{
|
||||||
mb: 2,
|
fontWeight: "bold",
|
||||||
color: "#0e467f",
|
textAlign: "center",
|
||||||
}}
|
mt: 3,
|
||||||
>
|
mb: 2,
|
||||||
{missionTitle}
|
color: "#0e467f",
|
||||||
</Typography>
|
}}
|
||||||
|
>
|
||||||
|
{missionTitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
{/* ✅ Accordéon dynamique */}
|
||||||
sx={{
|
{missions.length > 0 ? (
|
||||||
position: "relative",
|
missions.map((mission, index) => (
|
||||||
height: "400px",
|
<Accordion
|
||||||
backgroundImage: `url('https://via.placeholder.com/600x400')`, // Replace with the image URL or dynamic data
|
key={index}
|
||||||
backgroundSize: "cover",
|
expanded={expanded === `panel${index}`}
|
||||||
backgroundPosition: "center",
|
onChange={handleAccordionChange(`panel${index}`)}
|
||||||
}}
|
>
|
||||||
>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
{missionItems.map((item, index) => (
|
<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
|
<Box
|
||||||
key={index}
|
key={index}
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -1,134 +1,554 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { getPostById, updatePost, uploadImage } from "../wordpress";
|
import { getPostById, updatePost, uploadImageFromUrl } from "../wordpress";
|
||||||
import { getToken } from "../auth";
|
import { getToken } from "../auth";
|
||||||
import {
|
import {
|
||||||
Box, Container, Typography, TextField, Button, Paper, Input, IconButton
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ArrowBack, Publish } from "@mui/icons-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";
|
||||||
|
|
||||||
|
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.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() {
|
function EditPost() {
|
||||||
const { id } = useParams(); // ✅ Récupère l'ID de l'article depuis l'URL
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [file, setFile] = useState(null);
|
const [imageUrl, setImageUrl] = useState(null); // preview
|
||||||
|
const [imageFile, setImageFile] = useState(null); // pour upload
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [postMeta, setPostMeta] = useState({
|
||||||
|
date: null,
|
||||||
|
modified: null,
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ Vérification de l'authentification
|
// Auth
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) navigate("/admin/login");
|
||||||
navigate("/admin/login");
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// ✅ Récupérer les données de l'article
|
// Article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPost = async () => {
|
const fetchPost = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const post = await getPostById(id);
|
const post = await getPostById(id);
|
||||||
setTitle(post.title.rendered);
|
setTitle(post.title.rendered);
|
||||||
setContent(post.content.rendered.replace(/(<([^>]+)>)/gi, "")); // Enlever le HTML
|
setContent(post.content.rendered);
|
||||||
setLoading(false);
|
setImageUrl(post?.jetpack_featured_media_url || null);
|
||||||
|
setPostMeta({
|
||||||
|
date: post.date || null,
|
||||||
|
modified: post.modified || null,
|
||||||
|
status: post.status || "",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur chargement article :", error);
|
console.error("Erreur chargement article :", error);
|
||||||
navigate("/admin/gestion-articles"); // Redirige si erreur
|
navigate("/admin/gestion-articles");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPost();
|
fetchPost();
|
||||||
}, [id, navigate]);
|
}, [id, navigate]);
|
||||||
|
|
||||||
const handleUpdate = async (e) => {
|
// 🧠 convertit URL en File
|
||||||
e.preventDefault();
|
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 {
|
try {
|
||||||
const updatedPost = await updatePost(id, title, content, file);
|
setSubmitting(true);
|
||||||
alert("✅ Article mis à jour avec succès !");
|
let imageId = undefined;
|
||||||
navigate("/admin/gestion-articles"); // Redirige après modification
|
if (imageFile || imageUrl) {
|
||||||
|
imageId = await uploadImageFromUrl(imageUrl);
|
||||||
|
}
|
||||||
|
await updatePost(id, title, content, imageId);
|
||||||
|
alert("✅ Article mis à jour !");
|
||||||
|
navigate("/admin/gestion-articles");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("❌ Erreur lors de la mise à jour de l'article.");
|
console.error("❌ Erreur update :", error);
|
||||||
|
alert("❌ Erreur mise à jour.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Typography>Chargement...</Typography>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
className="glass-dashboard"
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
display: "flex",
|
position: "relative",
|
||||||
alignItems: "center",
|
px: { xs: 2, md: 6 },
|
||||||
justifyContent: "center",
|
pt: { xs: 6, md: 8 },
|
||||||
backgroundImage: "url('https://source.unsplash.com/1600x900/?office,writing')",
|
pb: { xs: 8, md: 10 },
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="sm">
|
<Box className="glass-orb orb-1" />
|
||||||
<Paper elevation={6} sx={{ padding: 4, borderRadius: 3 }}>
|
<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
|
<Button
|
||||||
startIcon={<ArrowBack />}
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => navigate("/admin/gestion-articles")}
|
onClick={() => navigate("/admin/gestion-articles")}
|
||||||
sx={{ mb: 2 }}
|
variant="contained"
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
sx={composeButtonSx("ghost")}
|
||||||
>
|
>
|
||||||
Retour à la gestion des articles
|
Retour aux articles
|
||||||
</Button>
|
</Button>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}>
|
<Typography
|
||||||
Modifier l'article
|
variant="overline"
|
||||||
</Typography>
|
sx={{
|
||||||
|
letterSpacing: 2,
|
||||||
<form onSubmit={handleUpdate}>
|
fontWeight: 700,
|
||||||
<TextField
|
color: "rgba(255,255,255,0.8)",
|
||||||
label="Titre de l'article"
|
}}
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
variant="outlined"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Contenu"
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Upload de l'image */}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
|
|
||||||
Nouvelle image (facultatif) :
|
|
||||||
</Typography>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => setFile(e.target.files[0])}
|
|
||||||
sx={{ display: "block", mb: 2 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
fullWidth
|
|
||||||
startIcon={<Publish />}
|
|
||||||
sx={{ mt: 3 }}
|
|
||||||
>
|
>
|
||||||
Mettre à jour
|
Publication Octopus
|
||||||
</Button>
|
</Typography>
|
||||||
</form>
|
<Typography
|
||||||
</Paper>
|
variant="h3"
|
||||||
</Container>
|
sx={{
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "-0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifier l’article
|
||||||
|
</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 l’article…
|
||||||
|
</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 />
|
||||||
|
• N’oubliez pas de relire la mise en forme dans l’aperç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 l’image 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 l’article dans la liste des publications
|
||||||
|
et sur les réseaux sociaux.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,134 @@
|
|||||||
import React, { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Box, Grid, Typography, Card, CardContent, Modal } from "@mui/material";
|
import { Box, Grid, Typography, Card, CardContent, Modal } from "@mui/material";
|
||||||
import pictoMaitriseOeuvre from "../assets/picto-maitrise-oeuvre.avif";
|
import pictoMaitriseOeuvre from "../assets/picto-maitrise-oeuvre.avif";
|
||||||
import pictoStructure from "../assets/picto-structure.avif";
|
import pictoStructure from "../assets/picto-structure.avif";
|
||||||
import pictoElectricite from "../assets/picto-electricite.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 = ({
|
const Expertices = ({
|
||||||
expertiseTitle,
|
expertiseTitle,
|
||||||
@@ -15,16 +141,12 @@ const Expertices = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [openModal, setOpenModal] = useState(null);
|
const [openModal, setOpenModal] = useState(null);
|
||||||
|
|
||||||
const handleOpenModal = (modalId) => {
|
const handleOpenModal = (modalId) => setOpenModal(modalId);
|
||||||
setOpenModal(modalId);
|
const handleCloseModal = () => setOpenModal(null);
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setOpenModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Fonction qui supprime les balises HTML et tronque le texte s'il est trop long
|
||||||
const truncateText = (text, maxLength) => {
|
const truncateText = (text, maxLength) => {
|
||||||
const plainText = text.replace(/<[^>]+>/g, ""); // Supprime les balises HTML
|
const plainText = text.replace(/<[^>]+>/g, "");
|
||||||
return plainText.length > maxLength
|
return plainText.length > maxLength
|
||||||
? `${plainText.substring(0, maxLength)}...`
|
? `${plainText.substring(0, maxLength)}...`
|
||||||
: plainText;
|
: plainText;
|
||||||
@@ -47,20 +169,7 @@ const Expertices = ({
|
|||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
{/* Expertise 1 */}
|
{/* Expertise 1 */}
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Card
|
<Card sx={cardStyle(pictoMaitriseOeuvre)}>
|
||||||
sx={{
|
|
||||||
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>
|
<CardContent>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h3"
|
variant="h3"
|
||||||
@@ -77,16 +186,7 @@ const Expertices = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenModal("maitrise_oeuvre")}
|
onClick={() => handleOpenModal("maitrise_oeuvre")}
|
||||||
style={{
|
style={buttonStyle}
|
||||||
marginTop: "16px",
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor: "#0e467f",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
textTransform: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
En savoir plus
|
En savoir plus
|
||||||
</button>
|
</button>
|
||||||
@@ -96,20 +196,7 @@ const Expertices = ({
|
|||||||
|
|
||||||
{/* Expertise 2 */}
|
{/* Expertise 2 */}
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Card
|
<Card sx={cardStyle(pictoStructure)}>
|
||||||
sx={{
|
|
||||||
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>
|
<CardContent>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h3"
|
variant="h3"
|
||||||
@@ -126,16 +213,7 @@ const Expertices = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenModal("structure")}
|
onClick={() => handleOpenModal("structure")}
|
||||||
style={{
|
style={buttonStyle}
|
||||||
marginTop: "16px",
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor: "#0e467f",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
textTransform: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
En savoir plus
|
En savoir plus
|
||||||
</button>
|
</button>
|
||||||
@@ -145,19 +223,7 @@ const Expertices = ({
|
|||||||
|
|
||||||
{/* Expertise 3 */}
|
{/* Expertise 3 */}
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Card
|
<Card sx={cardStyle(pictoElectricite)}>
|
||||||
sx={{
|
|
||||||
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>
|
<CardContent>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h3"
|
variant="h3"
|
||||||
@@ -174,16 +240,7 @@ const Expertices = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenModal("electricite")}
|
onClick={() => handleOpenModal("electricite")}
|
||||||
style={{
|
style={buttonStyle}
|
||||||
marginTop: "16px",
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor: "#0e467f",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
textTransform: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
En savoir plus
|
En savoir plus
|
||||||
</button>
|
</button>
|
||||||
@@ -192,124 +249,44 @@ const Expertices = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Modal 1 */}
|
{/* Modaux */}
|
||||||
<Modal open={openModal === "maitrise_oeuvre"} onClose={handleCloseModal}>
|
<ExpertiseModal
|
||||||
<Box
|
modalId="maitrise_oeuvre"
|
||||||
sx={{
|
image={pictoMaitriseOeuvre}
|
||||||
padding: 4,
|
title={expertise1Title}
|
||||||
borderRadius: "20px",
|
text={expertise1Text}
|
||||||
background: "rgba(255, 255, 255, 0.9)",
|
openModal={openModal}
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
handleClose={handleCloseModal}
|
||||||
backdropFilter: "blur(10px)",
|
/>
|
||||||
maxWidth: 600,
|
<ExpertiseModal
|
||||||
margin: "auto",
|
modalId="structure"
|
||||||
mt: 4,
|
image={pictoStructure}
|
||||||
position: "relative",
|
title={expertise2Title}
|
||||||
}}
|
text={expertise2Text}
|
||||||
>
|
openModal={openModal}
|
||||||
<Box
|
handleClose={handleCloseModal}
|
||||||
sx={{
|
/>
|
||||||
position: "absolute",
|
<ExpertiseModal
|
||||||
top: 16,
|
modalId="electricite"
|
||||||
right: 16,
|
image={pictoElectricite}
|
||||||
width: 80,
|
title={expertise3Title}
|
||||||
height: 80,
|
text={expertise3Text}
|
||||||
backgroundImage: `url(${pictoMaitriseOeuvre})`,
|
openModal={openModal}
|
||||||
backgroundSize: "cover",
|
handleClose={handleCloseModal}
|
||||||
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>
|
</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;
|
export default Expertices;
|
||||||
@@ -64,12 +64,13 @@ const Faq = ({ showFAQ }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h3"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
mb: 4,
|
mb: 4,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "#0e467f",
|
color: "#0e467f",
|
||||||
|
fontSize: { xs: "2rem", md: "2rem", lg: "2.5rem",whiteSpace: "pre-line" }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
FAQ
|
FAQ
|
||||||
@@ -102,7 +103,7 @@ const Faq = ({ showFAQ }) => {
|
|||||||
{filteredFaqs.length > 0 ? (
|
{filteredFaqs.length > 0 ? (
|
||||||
filteredFaqs.map((faq, index) => (
|
filteredFaqs.map((faq, index) => (
|
||||||
<Box key={index} sx={{ mb: 3 }}>
|
<Box key={index} sx={{ mb: 3 }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
|
<Typography variant="h3" sx={{ fontWeight: "bold",fontSize: { xs: "2rem", md: "2rem", lg: "2rem" } }}>
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "#0e467f",
|
backgroundColor: "#0e467f",
|
||||||
color: "white",
|
color: "white",
|
||||||
@@ -33,36 +34,30 @@ const Footer = () => {
|
|||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
component="h2"
|
component="h2"
|
||||||
sx={{ fontWeight: "bold", textAlign: { xs: "center", md: "left" } }}
|
sx={{ fontWeight: "bold", color:"#ffffff", textAlign: { xs: "center", md: "left" } }}
|
||||||
>
|
>
|
||||||
IN3 Intégrale Ingénierie Internationale
|
Octopusdesign
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||||
>
|
>
|
||||||
67 Boulevard Winston Churchill,
|
Route du Perrier,
|
||||||
<br />
|
<br />
|
||||||
72100 Le Mans, France
|
85270 - Saint Hilaire de Riez
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||||
>
|
>
|
||||||
<strong>Téléphone :</strong> 02.43.85.09.01
|
<strong>Téléphone :</strong> 06.00.00.00.00
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||||
>
|
>
|
||||||
<strong>Email :</strong>{" "}
|
<strong>Email :</strong>{" "}
|
||||||
<Link
|
contact@octopusdesign.fr
|
||||||
href="mailto:contact@be-in3.com"
|
|
||||||
underline="hover"
|
|
||||||
sx={{ color: "white" }}
|
|
||||||
>
|
|
||||||
contact@be-in3.com
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -192,7 +187,7 @@ const Footer = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
© {new Date().getFullYear()} IN3 Intégrale Ingénierie Internationale.
|
© {new Date().getFullYear()} Octopusdesign.
|
||||||
Tous droits réservés.
|
Tous droits réservés.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
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 l’introduction, 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 l’expertise 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 l’expertise 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 l’expertise"
|
||||||
|
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;
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
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 "Modifier" 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;
|
||||||
@@ -24,8 +24,8 @@ import ArticleIcon from "@mui/icons-material/Article";
|
|||||||
import InfoIcon from "@mui/icons-material/Info";
|
import InfoIcon from "@mui/icons-material/Info";
|
||||||
import ContactMailIcon from "@mui/icons-material/ContactMail";
|
import ContactMailIcon from "@mui/icons-material/ContactMail";
|
||||||
import WorkIcon from "@mui/icons-material/Work";
|
import WorkIcon from "@mui/icons-material/Work";
|
||||||
import Logo from "../assets/logo-in3.svg";
|
import Logo from "../assets/centre-formation-logo.avif";
|
||||||
import LogoM from "../assets/logo-in3-mobil.svg";
|
import LogoM from "../assets/centre-formation-logo.avif";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -123,7 +123,7 @@ const Header = () => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={Logo}
|
src={Logo}
|
||||||
alt="Bureau d'études Le Mans"
|
alt="centre de formation numérique"
|
||||||
style={{
|
style={{
|
||||||
width: "50px",
|
width: "50px",
|
||||||
height: "50px",
|
height: "50px",
|
||||||
@@ -150,7 +150,7 @@ const Header = () => {
|
|||||||
...commonButtonStyles,
|
...commonButtonStyles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Services
|
Activités
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
id="services-menu"
|
id="services-menu"
|
||||||
@@ -167,24 +167,45 @@ const Header = () => {
|
|||||||
onClick={handleMenuClose}
|
onClick={handleMenuClose}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 1 }} />
|
<WorkIcon sx={{ marginRight: 1 }} />
|
||||||
Prestation maitrise oeuvre
|
Création de site Web
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/services/structure-beton-charpente-metallique-bois"
|
to="/services/formations-web"
|
||||||
onClick={handleMenuClose}
|
onClick={handleMenuClose}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 1 }} />
|
<WorkIcon sx={{ marginRight: 1 }} />
|
||||||
Structure béton | charpente métallique / bois
|
Formations web
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/services/electricite"
|
to="/services/electricite"
|
||||||
onClick={handleMenuClose}
|
onClick={handleMenuClose}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 1}} />
|
<WorkIcon sx={{ marginRight: 1}} />
|
||||||
Électricité
|
Graphisme
|
||||||
</MenuItem>
|
</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>
|
</Menu>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -198,9 +219,9 @@ const Header = () => {
|
|||||||
...commonButtonStyles,
|
...commonButtonStyles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Réalisations
|
Actualités
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* <Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/about"
|
to="/about"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
@@ -212,10 +233,10 @@ const Header = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
À Propos
|
À Propos
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/bureauEtude"
|
to="/nosFormations"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: location.pathname === "/bureauEtude" ? "bold" : "normal",
|
fontWeight: location.pathname === "/bureauEtude" ? "bold" : "normal",
|
||||||
@@ -224,7 +245,7 @@ const Header = () => {
|
|||||||
...commonButtonStyles,
|
...commonButtonStyles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bureau d'étude
|
Formations
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -348,18 +369,19 @@ const Header = () => {
|
|||||||
selected={location.pathname.includes("/services/prestation-maitrise-oeuvre")}
|
selected={location.pathname.includes("/services/prestation-maitrise-oeuvre")}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="Prestation maitrise oeuvre" />
|
<ListItemText primary="Création site web" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
button
|
button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/services/structure-beton-charpente-metallique-bois"
|
to="/services/formations-web"
|
||||||
selected={location.pathname.includes("/services/structure-beton-charpente-metallique-bois")}
|
selected={location.pathname.includes("/services/formations-web")}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="Structure beton..." />
|
<ListItemText primary="Formations web..." />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
button
|
button
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -369,8 +391,33 @@ const Header = () => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="Electricité..." />
|
<ListItemText primary="Graphisme..." />
|
||||||
</ListItem>
|
</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
|
<ListItem
|
||||||
button
|
button
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -378,9 +425,10 @@ const Header = () => {
|
|||||||
selected={location.pathname === "/posts"}
|
selected={location.pathname === "/posts"}
|
||||||
>
|
>
|
||||||
<ArticleIcon sx={{ marginRight: 0.5 }} />
|
<ArticleIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="Réalisations" />
|
<ListItemText primary="Actualités..." />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
|
||||||
|
{/* <ListItem
|
||||||
button
|
button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/about"
|
to="/about"
|
||||||
@@ -388,7 +436,8 @@ const Header = () => {
|
|||||||
>
|
>
|
||||||
<InfoIcon sx={{ marginRight: 0.5 }} />
|
<InfoIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="À propos" />
|
<ListItemText primary="À propos" />
|
||||||
</ListItem>
|
</ListItem> */}
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
button
|
button
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -398,6 +447,7 @@ const Header = () => {
|
|||||||
<ContactMailIcon sx={{ marginRight: 0.5 }} />
|
<ContactMailIcon sx={{ marginRight: 0.5 }} />
|
||||||
<ListItemText primary="Contact" />
|
<ListItemText primary="Contact" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Typography, Button } from '@mui/material';
|
import { Box, Typography, Button } from '@mui/material';
|
||||||
|
|
||||||
|
|
||||||
const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor }) => {
|
const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor }) => {
|
||||||
// Priorité pour appliquer le fond : Image > Dégradé > Aucun fond
|
// Priorité pour appliquer le fond : Image > Dégradé > Aucun fond
|
||||||
const backgroundStyle = backgroundImage
|
const backgroundStyle = backgroundImage
|
||||||
@@ -60,9 +61,10 @@ const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
padding: { xs: '10px 20px', md: '15px 30px' }, // Responsive
|
padding: { xs: '10px 20px', md: '15px 30px' }, // Responsive
|
||||||
fontSize: { xs: '0.8rem', md: '1rem' },
|
fontSize: { xs: '0.8rem', md: '1rem' },
|
||||||
|
backgroundColor: '#315397',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Explorer les Articles
|
Nos Formations
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const CLOUD_NAME = "dh5qgexjo";
|
||||||
|
const UPLOAD_PRESET = "preset_articles"; // crée-le dans Cloudinary si ce n’est 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 l’URL optimisée
|
||||||
|
} else {
|
||||||
|
alert("❌ Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <input type="file" accept="image/*" onChange={handleUpload} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploaderCloudinary;
|
||||||
@@ -26,7 +26,7 @@ const About = () => {
|
|||||||
{
|
{
|
||||||
icon: <BusinessIcon />,
|
icon: <BusinessIcon />,
|
||||||
title: "Bureau d’études et Maitrise d’œuvre",
|
title: "Bureau d’études et Maitrise d’œuvre",
|
||||||
link: "/services/maitrise-oeuvre",
|
link: "/services/prestation-maitrise-oeuvre",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BuildIcon />,
|
icon: <BuildIcon />,
|
||||||
@@ -140,7 +140,7 @@ const About = () => {
|
|||||||
mb: 4, mt:5
|
mb: 4, mt:5
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
La société IN3
|
La société IN3 !!!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -15,26 +15,31 @@ const BureauEtudes = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPageData = async () => {
|
const fetchPageData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get("wp/v2/pages/272?_fields=acf"); // Remplace XXX par l'ID WordPress de la page
|
const response = await api.get("wp/v2/pages/272?_fields=acf");
|
||||||
|
console.log("expertises_specifiques:", response.data.acf.expertises_specifiques);
|
||||||
setPageData(response.data.acf);
|
setPageData(response.data.acf);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Erreur lors de la récupération des données ACF :", error);
|
||||||
"Erreur lors de la récupération des données ACF :",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPageData();
|
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) {
|
if (!pageData) {
|
||||||
return <Typography>Chargement...</Typography>;
|
return <Typography>Chargement...</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{}}>
|
<Box>
|
||||||
{/* Section Héros avec Image de Fond */}
|
{/* Section Héros */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
backgroundImage: "url('https://picsum.photos/1920/600.webp')",
|
backgroundImage: "url('https://picsum.photos/1920/600.webp')",
|
||||||
@@ -50,29 +55,33 @@ const BureauEtudes = () => {
|
|||||||
px: 4,
|
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 */}
|
{/* Domaines d'Intervention */}
|
||||||
<Box sx={{ mt: 6, px: 4 }}>
|
<Box sx={{ mt: 6, px: 4 }}>
|
||||||
<Typography variant="h2" sx={{
|
<Typography
|
||||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
variant="h2"
|
||||||
fontWeight: "bold",
|
sx={{ fontSize: "2.5rem", fontWeight: "bold", textAlign: "center", mb: 3 }}
|
||||||
textAlign: "center",
|
>
|
||||||
mb: 3 }}>
|
Nos formations sur mesure
|
||||||
Nos Domaines d'Intervention
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={4} justifyContent="center">
|
<Grid container spacing={4} justifyContent="center">
|
||||||
{pageData.liste_des_competences?.map((competence, index) => (
|
{pageData.liste_des_competences?.map((competence, index) => (
|
||||||
@@ -89,16 +98,15 @@ const BureauEtudes = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h3" sx={{
|
<Typography
|
||||||
fontWeight: "bold",
|
variant="h3"
|
||||||
mb: 1,
|
sx={{ fontWeight: "bold", mb: 1, fontSize: "1.8rem" }}
|
||||||
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
|
>
|
||||||
}}>
|
|
||||||
{competence.comp_titre}
|
{competence.comp_titre}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{
|
<Typography variant="body2" sx={{ textAlign: "center" }}>
|
||||||
textAlign:"center"
|
{competence.comp_description}
|
||||||
}}>{competence.comp_description}</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -108,11 +116,7 @@ const BureauEtudes = () => {
|
|||||||
|
|
||||||
{/* Compétences Spécifiques */}
|
{/* Compétences Spécifiques */}
|
||||||
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
|
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
|
||||||
<Typography variant="h2" sx={{
|
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
|
||||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
|
||||||
fontWeight: "bold",
|
|
||||||
mb: 3
|
|
||||||
}}>
|
|
||||||
Nos Compétences
|
Nos Compétences
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ maxWidth: "900px", mx: "auto" }}>
|
<Typography variant="body1" sx={{ maxWidth: "900px", mx: "auto" }}>
|
||||||
@@ -131,41 +135,40 @@ const BureauEtudes = () => {
|
|||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h2" sx={{
|
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
|
||||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
|
||||||
fontWeight: "bold",
|
|
||||||
mb: 3
|
|
||||||
}}>
|
|
||||||
Engagement et Accompagnement
|
Engagement et Accompagnement
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={4} justifyContent="center">
|
<Grid container spacing={4} justifyContent="center">
|
||||||
{pageData.expertises_specifiques?.map((expertise, index) => (
|
{expertises.length > 0 ? (
|
||||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
expertises.map((expertise, index) => (
|
||||||
<Card
|
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||||
sx={{
|
<Card
|
||||||
textAlign: "center",
|
sx={{
|
||||||
padding: 3,
|
textAlign: "center",
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
padding: 3,
|
||||||
backdropFilter: "blur(10px)",
|
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||||
transition: "transform 0.3s, box-shadow 0.3s",
|
backdropFilter: "blur(10px)",
|
||||||
"&:hover": {
|
transition: "transform 0.3s, box-shadow 0.3s",
|
||||||
transform: "scale(1.05)",
|
"&:hover": {
|
||||||
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
|
transform: "scale(1.05)",
|
||||||
},
|
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<CardContent>
|
>
|
||||||
<Typography variant="h3" sx={{
|
<CardContent>
|
||||||
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
|
<Typography variant="h3" sx={{ fontSize: "1.8rem", fontWeight: "bold", mb: 1 }}>
|
||||||
fontWeight: "bold",
|
{expertise.expertise_nom}
|
||||||
mb: 1 }}>
|
</Typography>
|
||||||
{expertise.expertise_nom}
|
<Typography variant="body2">
|
||||||
</Typography>
|
{expertise.expertise_details}
|
||||||
<Typography variant="body2">{expertise.expertise_details}</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<Typography>Aucune expertise spécifique à afficher.</Typography>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import Faq from "../Faq";
|
|||||||
|
|
||||||
|
|
||||||
const Contact = () => {
|
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 [metaTitle, setMetaTitle] = useState("Contactez-nous");
|
||||||
const [metaDescription, setMetaDescription] = useState(
|
const [metaDescription, setMetaDescription] = useState(
|
||||||
"Contactez notre équipe pour plus d'informations."
|
"Contactez notre équipe pour plus d'informations."
|
||||||
@@ -35,7 +41,11 @@ const Contact = () => {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [successMessage, setSuccessMessage] = useState(false);
|
const [alertState, setAlertState] = useState({
|
||||||
|
open: false,
|
||||||
|
severity: "success",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
const [openLightbox, setOpenLightbox] = useState(false);
|
const [openLightbox, setOpenLightbox] = useState(false);
|
||||||
|
|
||||||
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
|
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
|
||||||
@@ -96,7 +106,7 @@ const Contact = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://votre-site.com/wp-json/custom/v1/contact",
|
CONTACT_API_URL,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -107,7 +117,11 @@ const Contact = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setSuccessMessage(true);
|
setAlertState({
|
||||||
|
open: true,
|
||||||
|
severity: "success",
|
||||||
|
message: "Merci ! Votre message a bien été envoyé.",
|
||||||
|
});
|
||||||
setFormData({
|
setFormData({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -120,7 +134,13 @@ const Contact = () => {
|
|||||||
throw new Error(errorData.message || "Une erreur est survenue.");
|
throw new Error(errorData.message || "Une erreur est survenue.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error.message);
|
setAlertState({
|
||||||
|
open: true,
|
||||||
|
severity: "error",
|
||||||
|
message:
|
||||||
|
error.message ||
|
||||||
|
"Impossible d'envoyer votre message. Veuillez réessayer.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -135,6 +155,13 @@ const Contact = () => {
|
|||||||
setOpenLightbox(false);
|
setOpenLightbox(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseAlert = (_, reason) => {
|
||||||
|
if (reason === "clickaway") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAlertState((prev) => ({ ...prev, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* SEO */}
|
{/* SEO */}
|
||||||
@@ -216,14 +243,14 @@ const Contact = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
<strong>Adresse :</strong>67 Bd Winston Churchill, Le Mans,
|
<strong>Adresse :</strong>Route du Perrier, Saint HIlaire de Riez,
|
||||||
France
|
France
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
<strong>Téléphone :</strong> 02.43.85.09.01
|
<strong>Téléphone :</strong> 06.00.00.00.00
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
<strong>Email :</strong> contact@be-in3.com
|
<strong>Email :</strong> contact@octopusdesign.fr
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -278,7 +305,7 @@ const Contact = () => {
|
|||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h2"
|
variant="h3"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
@@ -332,7 +359,7 @@ const Contact = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
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'%C3%A9tudes%20Ing%C3%A9nieries%20Ma%C3%AEtrise%20d'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"
|
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"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="400"
|
height="400"
|
||||||
title="Carte interactive"
|
title="Carte interactive"
|
||||||
@@ -490,12 +517,17 @@ const Contact = () => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={successMessage}
|
open={alertState.open}
|
||||||
autoHideDuration={6000}
|
autoHideDuration={6000}
|
||||||
onClose={() => setSuccessMessage(false)}
|
onClose={handleCloseAlert}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
>
|
>
|
||||||
<Alert onClose={() => setSuccessMessage(false)} severity="success">
|
<Alert
|
||||||
Message envoyé avec succès !
|
onClose={handleCloseAlert}
|
||||||
|
severity={alertState.severity}
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
{alertState.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,131 +1,470 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { uploadImage, createPost } from "../../wordpress";
|
import { uploadImageFromUrl, createPost } from "../../wordpress";
|
||||||
import { getToken } from "../../auth";
|
import { getToken } from "../../auth";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Typography,
|
Typography,
|
||||||
TextField,
|
TextField,
|
||||||
Button,
|
Button,
|
||||||
Paper,
|
Grid,
|
||||||
Input,
|
Card,
|
||||||
IconButton,
|
CardContent,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ArrowBack, Publish } from "@mui/icons-material";
|
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
|
||||||
|
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||||
|
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||||
|
import 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),
|
||||||
|
});
|
||||||
|
|
||||||
function CreatePost() {
|
function CreatePost() {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [file, setFile] = useState(null);
|
const [imageUrl, setImageUrl] = useState(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ✅ Vérification de l'authentification : Redirection vers /login si l'utilisateur n'est pas connecté
|
// Vérification de l'authentification
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
navigate("/admin/login");
|
navigate("/admin/login");
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const canPublish =
|
||||||
e.preventDefault();
|
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canPublish || submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
console.log("📢 Tentative d'upload de l'image...");
|
setSubmitting(true);
|
||||||
const imageId = await uploadImage(file);
|
const imageId = await uploadImageFromUrl(imageUrl);
|
||||||
|
|
||||||
console.log("📢 Création du post avec l'image mise en avant...");
|
|
||||||
await createPost(title, content, imageId);
|
await createPost(title, content, imageId);
|
||||||
|
alert("✅ Article publié !");
|
||||||
alert("✅ Post créé avec succès !");
|
navigate("/admin/gestion-articles");
|
||||||
navigate("/admin/gestion-articles"); // Redirection après la création
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"❌ Erreur création post :",
|
||||||
|
error?.response?.data || error?.message || error
|
||||||
|
);
|
||||||
alert("❌ Erreur lors de la création du post.");
|
alert("❌ Erreur lors de la création du post.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
className="glass-dashboard"
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
display: "flex",
|
position: "relative",
|
||||||
alignItems: "center",
|
px: { xs: 2, md: 6 },
|
||||||
justifyContent: "center",
|
pt: { xs: 6, md: 8 },
|
||||||
backgroundImage: "url('https://source.unsplash.com/1600x900/?office,writing')",
|
pb: { xs: 8, md: 10 },
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="sm">
|
<Box className="glass-orb orb-1" />
|
||||||
<Paper elevation={6} sx={{ padding: 4, borderRadius: 3 }}>
|
<Box className="glass-orb orb-2" />
|
||||||
{/* ✅ Bouton Retour à la Gestion des Articles */}
|
<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
|
<Button
|
||||||
startIcon={<ArrowBack />}
|
startIcon={<ArrowBack />}
|
||||||
variant="outlined"
|
variant="contained"
|
||||||
color="secondary"
|
|
||||||
onClick={() => navigate("/admin/gestion-articles")}
|
onClick={() => navigate("/admin/gestion-articles")}
|
||||||
sx={{ mb: 2 }}
|
sx={composeButtonSx("ghost")}
|
||||||
>
|
>
|
||||||
Retour à la gestion des articles
|
Retour aux articles
|
||||||
</Button>
|
</Button>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
{/* ✅ Titre de la page */}
|
<Typography
|
||||||
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}>
|
variant="overline"
|
||||||
Créer un article
|
sx={{
|
||||||
</Typography>
|
letterSpacing: 2,
|
||||||
|
fontWeight: 700,
|
||||||
{/* ✅ Formulaire */}
|
color: "rgba(255,255,255,0.8)",
|
||||||
<form onSubmit={handleSubmit}>
|
}}
|
||||||
<TextField
|
|
||||||
label="Titre de l'article"
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
variant="outlined"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Contenu"
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ✅ Upload de l'image */}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
|
|
||||||
Image en vedette :
|
|
||||||
</Typography>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => setFile(e.target.files[0])}
|
|
||||||
required
|
|
||||||
sx={{ display: "block", mb: 2 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ✅ Bouton Publier */}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
fullWidth
|
|
||||||
startIcon={<Publish />}
|
|
||||||
sx={{ mt: 3 }}
|
|
||||||
>
|
>
|
||||||
Publier l'article
|
Publication Octopus
|
||||||
</Button>
|
</Typography>
|
||||||
</form>
|
<Typography
|
||||||
</Paper>
|
variant="h3"
|
||||||
</Container>
|
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><h2></code>) pour
|
||||||
|
clarifier la structure.<br />
|
||||||
|
• Ajoutez des appels à l’action 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 l’image 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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,119 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
IconButton,
|
|
||||||
Button,
|
Button,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import LogoutIcon from "@mui/icons-material/Logout"; // Icône de déconnexion
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
import ArticleIcon from "@mui/icons-material/Article";
|
import ArticleIcon from "@mui/icons-material/Article";
|
||||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import HomeIcon from "@mui/icons-material/Home"; // Icône pour la gestion page d'accueil
|
import BuildIcon from "@mui/icons-material/Build";
|
||||||
|
import SchoolIcon from "@mui/icons-material/School";
|
||||||
|
import InsightsIcon from "@mui/icons-material/Insights";
|
||||||
|
import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest";
|
||||||
|
import AutoStoriesIcon from "@mui/icons-material/AutoStories";
|
||||||
|
import PublicIcon from "@mui/icons-material/Public";
|
||||||
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
|
|
||||||
|
const BUTTON_BASE_SX = Object.freeze({
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
transition:
|
||||||
|
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
},
|
||||||
|
"&:active": {
|
||||||
|
transform: "translateY(0)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUTTON_SIZE_SX = Object.freeze({
|
||||||
|
small: {
|
||||||
|
px: 2.1,
|
||||||
|
py: 0.55,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
minHeight: 30,
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
px: 2.8,
|
||||||
|
py: 0.8,
|
||||||
|
fontSize: "0.86rem",
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
px: 3.4,
|
||||||
|
py: 1,
|
||||||
|
fontSize: "0.94rem",
|
||||||
|
minHeight: 42,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUTTON_VARIANT_SX = Object.freeze({
|
||||||
|
primary: {
|
||||||
|
background: "linear-gradient(135deg, #315397, #7bc0ff)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "1px solid rgba(255,255,255,0.35)",
|
||||||
|
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
|
||||||
|
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
|
||||||
|
},
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
color: "rgba(255,255,255,0.72)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
|
||||||
|
boxShadow: "none",
|
||||||
|
border: "1px solid rgba(49,83,151,0.18)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
background: "rgba(255,255,255,0.16)",
|
||||||
|
color: "rgba(255,255,255,0.92)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.28)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "rgba(255,255,255,0.24)",
|
||||||
|
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
background: "rgba(255,255,255,0.84)",
|
||||||
|
color: "#315397",
|
||||||
|
border: "1px solid rgba(49,83,151,0.32)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "rgba(255,255,255,0.96)",
|
||||||
|
borderColor: "rgba(49,83,151,0.48)",
|
||||||
|
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtle: {
|
||||||
|
background: "rgba(49,83,151,0.08)",
|
||||||
|
color: "#315397",
|
||||||
|
border: "1px solid rgba(49,83,151,0.12)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "rgba(49,83,151,0.14)",
|
||||||
|
borderColor: "rgba(49,83,151,0.24)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const composeButtonSx = (variant, size = "medium") => ({
|
||||||
|
...BUTTON_BASE_SX,
|
||||||
|
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
|
||||||
|
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
|
||||||
|
});
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -30,117 +132,390 @@ function Dashboard() {
|
|||||||
navigate("/admin/login"); // Redirige vers la page de connexion
|
navigate("/admin/login"); // Redirige vers la page de connexion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
title: "Page d’accueil",
|
||||||
|
description: "Mettez à jour les blocs héros, témoignages et sections clés.",
|
||||||
|
navigateTo: "/admin/Gestion-Page-Accueil",
|
||||||
|
icon: <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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
className="glass-dashboard"
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
backgroundImage: "url('https://source.unsplash.com/1600x900/?technology,office')",
|
position: "relative",
|
||||||
backgroundSize: "cover",
|
px: { xs: 2, md: 6 },
|
||||||
backgroundPosition: "center",
|
pt: { xs: 6, md: 8 },
|
||||||
display: "flex",
|
pb: { xs: 8, md: 10 },
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "20px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box className="glass-orb orb-1" />
|
||||||
sx={{
|
<Box className="glass-orb orb-2" />
|
||||||
width: "90%",
|
<Box className="glass-orb orb-3" />
|
||||||
maxWidth: "900px",
|
|
||||||
padding: 4,
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
|
||||||
borderRadius: 3,
|
|
||||||
boxShadow: 6,
|
|
||||||
textAlign: "center",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Bouton de déconnexion placé en haut à gauche */}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
startIcon={<LogoutIcon />}
|
|
||||||
onClick={handleLogout}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
left: 10,
|
|
||||||
backgroundColor: "#d32f2f",
|
|
||||||
"&:hover": { backgroundColor: "#b71c1c" },
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
padding: "6px 12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* En-tête */}
|
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4, mt: 4 }}>
|
<Stack
|
||||||
<DashboardIcon sx={{ fontSize: 40, color: "#0e467f", mr: 1 }} />
|
direction={{ xs: "column", md: "row" }}
|
||||||
Tableau de Bord
|
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 à l’ensemble 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
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Cartes du Dashboard */}
|
<Grid container spacing={2.5}>
|
||||||
<Grid container spacing={3} justifyContent="center">
|
{NAV_ITEMS.map((item) => (
|
||||||
{/* ✅ Gestion Page d'Accueil - Première carte */}
|
<Grid item xs={12} md={6} key={item.title}>
|
||||||
<Grid item xs={12} sm={6}>
|
<Card
|
||||||
<Card
|
onClick={() => navigate(item.navigateTo)}
|
||||||
onClick={() => navigate("/admin/Gestion-Page-Accueil")}
|
className="glass-subcard"
|
||||||
sx={{
|
sx={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
textAlign: "center",
|
borderRadius: 3,
|
||||||
padding: 2,
|
overflow: "hidden",
|
||||||
transition: "0.3s",
|
position: "relative",
|
||||||
"&:hover": {
|
transition:
|
||||||
backgroundColor: "#0e467f",
|
"transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease",
|
||||||
color: "#ffffff",
|
"&::before": {
|
||||||
transform: "scale(1.05)",
|
content: '""',
|
||||||
boxShadow: 8,
|
position: "absolute",
|
||||||
},
|
inset: 0,
|
||||||
"&:hover svg": {
|
borderRadius: "inherit",
|
||||||
fill: "#ffffff",
|
background: `radial-gradient(360px at 18% 24%, ${item.accent}, transparent 72%)`,
|
||||||
},
|
opacity: 0.9,
|
||||||
}}
|
},
|
||||||
>
|
"&:hover": {
|
||||||
<CardContent>
|
transform: "translateY(-8px)",
|
||||||
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
|
borderColor: "rgba(49,83,151,0.35)",
|
||||||
<HomeIcon fontSize="inherit" />
|
boxShadow: "0 38px 40px -30px rgba(12, 29, 74, 0.6)",
|
||||||
</IconButton>
|
},
|
||||||
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
}}
|
||||||
Gestion Page d'Accueil
|
>
|
||||||
</Typography>
|
<CardContent
|
||||||
</CardContent>
|
sx={{
|
||||||
</Card>
|
position: "relative",
|
||||||
</Grid>
|
zIndex: 1,
|
||||||
|
display: "flex",
|
||||||
{/* ✅ Gérer les Articles - Deuxième carte */}
|
flexDirection: "column",
|
||||||
<Grid item xs={12} sm={6}>
|
gap: 1.5,
|
||||||
<Card
|
p: { xs: 3, md: 3.5 },
|
||||||
onClick={() => navigate("/admin/gestion-articles")}
|
}}
|
||||||
sx={{
|
>
|
||||||
cursor: "pointer",
|
<Box
|
||||||
textAlign: "center",
|
sx={{
|
||||||
padding: 2,
|
width: 52,
|
||||||
transition: "0.3s",
|
height: 52,
|
||||||
"&:hover": {
|
borderRadius: 2,
|
||||||
backgroundColor: "#0e467f",
|
backgroundColor: "rgba(255,255,255,0.75)",
|
||||||
color: "#ffffff",
|
display: "flex",
|
||||||
transform: "scale(1.05)",
|
alignItems: "center",
|
||||||
boxShadow: 8,
|
justifyContent: "center",
|
||||||
},
|
color: "#315397",
|
||||||
"&:hover svg": {
|
boxShadow: "0 16px 26px -20px rgba(11, 26, 61, 0.5)",
|
||||||
fill: "#ffffff",
|
fontSize: 28,
|
||||||
},
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{item.icon}
|
||||||
<CardContent>
|
</Box>
|
||||||
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
|
<Typography
|
||||||
<ArticleIcon fontSize="inherit" />
|
variant="h6"
|
||||||
</IconButton>
|
sx={{ color: "#0b1a3d", fontWeight: 700 }}
|
||||||
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
>
|
||||||
Gérer les Articles
|
{item.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
<Typography
|
||||||
</Card>
|
variant="body2"
|
||||||
</Grid>
|
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>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,118 @@
|
|||||||
// ✅ Importations nécessaires
|
// ✅ Importations nécessaires
|
||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Box, Typography, Button, Table, TableBody, TableCell,
|
Box,
|
||||||
TableContainer, TableHead, TableRow, Paper, TextField, Avatar
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
TextField,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Add, ArrowBack, Edit, Delete } from "@mui/icons-material";
|
import { Add, ArrowBack, Edit, Delete } from "@mui/icons-material";
|
||||||
import api from "../../api";
|
import api from "../../api";
|
||||||
import { getToken } from "../../auth";
|
import { getToken } from "../../auth";
|
||||||
import { deletePost } from "../../wordpress";
|
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 GestionArticles = () => {
|
||||||
const [posts, setPosts] = useState([]);
|
const [posts, setPosts] = useState([]);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ✅ Vérifier si l'utilisateur est connecté
|
// ✅ Vérifier si l'utilisateur est connecté
|
||||||
@@ -26,7 +126,10 @@ const GestionArticles = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get("wp/v2/posts?_fields=id,title,excerpt,featured_media");
|
setLoading(true);
|
||||||
|
const response = await api.get(
|
||||||
|
"wp/v2/posts?_fields=id,title,excerpt,featured_media,date,status"
|
||||||
|
);
|
||||||
const postsData = response.data;
|
const postsData = response.data;
|
||||||
|
|
||||||
const postsWithImages = await Promise.all(
|
const postsWithImages = await Promise.all(
|
||||||
@@ -47,6 +150,8 @@ const GestionArticles = () => {
|
|||||||
setPosts(postsWithImages);
|
setPosts(postsWithImages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Erreur chargement des articles :", error);
|
console.error("❌ Erreur chargement des articles :", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,110 +159,406 @@ const GestionArticles = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ✅ Supprimer un article et son image associée
|
// ✅ Supprimer un article et son image associée
|
||||||
const handleDeletePost = async (postId, imageId) => {
|
const stripHtml = (value = "") =>
|
||||||
if (window.confirm("Es-tu sûr de vouloir supprimer cet article ?")) {
|
value.replace(/(<([^>]+)>)/gi, "").replace(/ /gi, " ").trim();
|
||||||
try {
|
|
||||||
await deletePost(postId, imageId); // ✅ Appel de la fonction deletePost
|
|
||||||
setPosts(posts.filter((post) => post.id !== postId)); // ✅ Met à jour la liste après suppression
|
|
||||||
alert("✅ Article supprimé avec succès !");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Erreur suppression article :", error);
|
|
||||||
alert("⚠ Erreur : impossible de supprimer l'article.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ Filtrage des articles
|
const previewExcerpt = (value = "", limit = 160) => {
|
||||||
const filteredPosts = posts.filter((post) =>
|
const clean = stripHtml(value);
|
||||||
post.title.rendered.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
return clean.length > limit ? `${clean.slice(0, limit)}…` : clean;
|
||||||
post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/ /g, " ").toLowerCase().includes(searchTerm.toLowerCase())
|
};
|
||||||
);
|
|
||||||
|
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 (
|
return (
|
||||||
<Box sx={{ padding: "40px 20px" }}>
|
<Box
|
||||||
{/* ✅ En-tête avec retour et création */}
|
className="glass-dashboard"
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 4, mt:5 }}>
|
sx={{
|
||||||
<Button startIcon={<ArrowBack />} variant="outlined" color="secondary" onClick={() => navigate("/admin/dashboard")}>
|
minHeight: "100vh",
|
||||||
Retour au Dashboard
|
position: "relative",
|
||||||
</Button>
|
px: { xs: 2, md: 6 },
|
||||||
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", flexGrow: 1 }}>
|
pt: { xs: 6, md: 8 },
|
||||||
Gestion des Articles
|
pb: { xs: 8, md: 10 },
|
||||||
</Typography>
|
}}
|
||||||
<Button startIcon={<Add />} variant="contained" color="primary" onClick={() => navigate("/admin/create-post")}>
|
>
|
||||||
Créer un article
|
<Box className="glass-orb orb-1" />
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
{/* ✅ Recherche */}
|
|
||||||
<TextField
|
|
||||||
label="Rechercher un article..."
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ✅ Tableau des articles */}
|
|
||||||
<TableContainer component={Paper}>
|
|
||||||
<Table>
|
|
||||||
<TableHead sx={{ backgroundColor: "#0e467f" }}>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Image</TableCell>
|
|
||||||
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Titre</TableCell>
|
|
||||||
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Résumé</TableCell>
|
|
||||||
<TableCell sx={{ color: "white", fontWeight: "bold", textAlign: "center" }}>Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{filteredPosts.length > 0 ? (
|
|
||||||
filteredPosts.map((post) => (
|
|
||||||
<TableRow key={post.id}>
|
|
||||||
{/* ✅ Image en vedette */}
|
|
||||||
<TableCell>
|
|
||||||
<Avatar
|
|
||||||
src={post.image || "https://via.placeholder.com/100"}
|
|
||||||
variant="rounded"
|
|
||||||
sx={{ width: 80, height: 80 }}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{/* ✅ Titre de l'article */}
|
|
||||||
<TableCell sx={{ fontWeight: "bold" }}>{post.title.rendered}</TableCell>
|
|
||||||
{/* ✅ Résumé avec 100 caractères max */}
|
|
||||||
<TableCell>
|
|
||||||
{post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/ /g, " ").substring(0, 100)}...
|
|
||||||
</TableCell>
|
|
||||||
{/* ✅ Boutons Modifier et Supprimer */}
|
|
||||||
<TableCell sx={{ textAlign: "center" }}>
|
|
||||||
<Button startIcon={<Edit />}
|
|
||||||
variant="outlined"
|
|
||||||
color="warning"
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
onClick={() => navigate(`/admin/edit-post/${post.id}`)}
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<Delete />}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => handleDeletePost(post.id, post.imageId)}
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} sx={{ textAlign: "center", py: 2 }}>
|
|
||||||
Aucun article trouvé.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,17 @@ import api from "../../api";
|
|||||||
import ModernSpinner from "../SpinnerModerne";
|
import ModernSpinner from "../SpinnerModerne";
|
||||||
import Expertises from "../Expertises";
|
import Expertises from "../Expertises";
|
||||||
import ConstructSection from "../ConstructSection";
|
import ConstructSection from "../ConstructSection";
|
||||||
import { Box, Grid, Typography, Button, Card, CardContent } from "@mui/material";
|
import {
|
||||||
import Logo from "../../assets/logo-in3-mobil.svg";
|
Box,
|
||||||
|
Grid,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "@mui/material";
|
||||||
|
import Logo from "../../assets/centre-formation-logo.avif";
|
||||||
import Testi from "../Testi";
|
import Testi from "../Testi";
|
||||||
|
import "../../assets/style.css";
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const [pageData, setPageData] = useState(null);
|
const [pageData, setPageData] = useState(null);
|
||||||
@@ -22,14 +30,22 @@ const Home = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 🔥 Ajout d'un timestamp pour éviter la mise en cache
|
// 🔥 Ajout d'un timestamp pour éviter la mise en cache
|
||||||
const response = await api.get(`wp/v2/pages/13?_fields=acf,rank_math_title,rank_math_description&_=${new Date().getTime()}`);
|
const response = await api.get(
|
||||||
|
`wp/v2/pages/13?_fields=acf,link,rank_math_title,rank_math_description&_=${new Date().getTime()}`
|
||||||
|
);
|
||||||
const pageContent = response.data;
|
const pageContent = response.data;
|
||||||
setPageData(pageContent);
|
setPageData(pageContent);
|
||||||
|
|
||||||
// Récupération de l'image Hero
|
// Récupération de l'image Hero (Cloudinary ou WordPress)
|
||||||
const heroImageId = pageContent.acf?.img_hero;
|
if (
|
||||||
if (heroImageId) {
|
pageContent.acf?.img_hero_url &&
|
||||||
const mediaResponse = await api.get(`wp/v2/media/${heroImageId}?_=${new Date().getTime()}`);
|
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()}`
|
||||||
|
);
|
||||||
setHeroImage(mediaResponse.data.source_url);
|
setHeroImage(mediaResponse.data.source_url);
|
||||||
} else {
|
} else {
|
||||||
setHeroImage(null);
|
setHeroImage(null);
|
||||||
@@ -43,7 +59,7 @@ const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPageData();
|
fetchPageData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -63,19 +79,26 @@ const Home = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { acf, rank_math_title, rank_math_description, link } = pageData || {};
|
||||||
|
const fallbackUrl =
|
||||||
const { acf, rank_math_title, rank_math_description } = pageData || {};
|
typeof window !== "undefined" ? window.location.href : "";
|
||||||
|
const pageUrl = link || fallbackUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* SEO */}
|
{/* SEO */}
|
||||||
<SEO
|
<SEO
|
||||||
postId={13} // ID WordPress valide
|
pageUrl={pageUrl}
|
||||||
defaultTitle={rank_math_title || "Accueil - Mon Site"}
|
defaultTitle={rank_math_title || "centre de formation "}
|
||||||
defaultDescription={rank_math_description || "Bienvenue sur notre site !"}
|
defaultDescription={
|
||||||
defaultCanonicalUrl={acf?.canonical_url || window.location.href}
|
rank_math_description ||
|
||||||
defaultOgImage={heroImage || "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"}
|
"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"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section Héros */}
|
{/* Section Héros */}
|
||||||
@@ -90,18 +113,22 @@ const Home = () => {
|
|||||||
|
|
||||||
{/* Section Construisons */}
|
{/* Section Construisons */}
|
||||||
<ConstructSection
|
<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"}
|
constructSubtitle={acf?.gdc_construct?.construct_subtitle || "ensemble"}
|
||||||
constructText={acf?.gdc_construct?.construct_text || "Texte par défaut"}
|
constructText={acf?.gdc_construct?.construct_text || "Texte par défaut"}
|
||||||
constructSector={acf?.gdc_construct?.construct_sector || "Secteurs"}
|
constructSector={acf?.gdc_construct?.construct_sector || "Secteurs"}
|
||||||
constructNoteText={acf?.gdc_construct?.construct_note || "Note société"}
|
constructNoteText={acf?.gdc_construct?.construct_note || "Note société"}
|
||||||
missionTitle={acf?.mission_title || "Nos missions principales"}
|
missionTitle={acf?.mission_title || "Nos missions principales"}
|
||||||
missionItems={acf?.mission_items || []}
|
missionItems={acf?.mission || []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section Expertises */}
|
{/* Section Expertises */}
|
||||||
<Expertises
|
<Expertises
|
||||||
expertiseTitle={acf?.titre_expertise || "Pourquoi Nous Choisir ?"}
|
expertiseTitle={
|
||||||
|
acf?.titre_expertise.titre_expertise || "Pourquoi Nous Choisir ?"
|
||||||
|
}
|
||||||
expertise1Title={acf?.gdc_expert?.titre_expertise_1 || "Expertise 1"}
|
expertise1Title={acf?.gdc_expert?.titre_expertise_1 || "Expertise 1"}
|
||||||
expertise1Text={acf?.gdc_expert?.text_expertise_1 || "Text Expertise 1"}
|
expertise1Text={acf?.gdc_expert?.text_expertise_1 || "Text Expertise 1"}
|
||||||
expertise2Title={acf?.gdc_expert?.titre_expertise_2 || "Expertise 2"}
|
expertise2Title={acf?.gdc_expert?.titre_expertise_2 || "Expertise 2"}
|
||||||
@@ -110,16 +137,27 @@ const Home = () => {
|
|||||||
expertise3Text={acf?.gdc_expert?.text_expertise_3 || "Text Expertise 3"}
|
expertise3Text={acf?.gdc_expert?.text_expertise_3 || "Text Expertise 3"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Section Témoignages */}
|
{/* Section Témoignages */}
|
||||||
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, textAlign: "center", fontSize: { xs: "2rem", md: "2rem", lg: "3rem" } }}>
|
<Typography
|
||||||
Ce que disent nos clients
|
variant="h2"
|
||||||
</Typography>
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
mb: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ce que disent nos clients
|
||||||
|
</Typography>
|
||||||
<Testi />
|
<Testi />
|
||||||
{/* Section Action */}
|
{/* Section Action */}
|
||||||
<Box sx={{ py: 6, px: 4, backgroundColor: "#0e467f", color: "#fff" }}>
|
<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 ?
|
Prêt à Commencer ?
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
@@ -142,7 +180,15 @@ const Home = () => {
|
|||||||
|
|
||||||
{/* Section Témoignages */}
|
{/* Section Témoignages */}
|
||||||
<Box sx={{ py: 6, px: 4 }}>
|
<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
|
Nos partenaires
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
@@ -152,8 +198,11 @@ const Home = () => {
|
|||||||
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
||||||
Un service exceptionnel, une équipe formidable
|
Un service exceptionnel, une équipe formidable
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
|
<Typography
|
||||||
- Client 1
|
variant="body2"
|
||||||
|
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
- zertides
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -164,7 +213,10 @@ const Home = () => {
|
|||||||
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
||||||
Des résultats impressionnants pour notre projet.
|
Des résultats impressionnants pour notre projet.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
- Client 2
|
- Client 2
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,29 +4,35 @@ import { useNavigate } from "react-router-dom"; // Import de useNavigate pour la
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Grid,
|
||||||
CardContent,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
Container,
|
Stack,
|
||||||
Avatar,
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||||
|
import "../../assets/styleCours.css";
|
||||||
|
|
||||||
function TestLogin() {
|
function TestLogin() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [appPassword, setAppPassword] = useState("");
|
const [appPassword, setAppPassword] = useState("");
|
||||||
const [token, setToken] = useState(getToken());
|
const [token, setToken] = useState(getToken());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
const navigate = useNavigate(); // Hook pour la navigation
|
const navigate = useNavigate(); // Hook pour la navigation
|
||||||
|
|
||||||
const handleLogin = async (e) => {
|
const handleLogin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
const newToken = await loginWithAppPassword(username, appPassword);
|
const newToken = await loginWithAppPassword(username, appPassword);
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
} else {
|
} else {
|
||||||
alert("Échec de la connexion !");
|
setError("Échec de la connexion. Vérifiez vos identifiants.");
|
||||||
}
|
}
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -34,90 +40,230 @@ function TestLogin() {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const composeButtonSx = (variant = "primary") => ({
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
transition:
|
||||||
|
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
|
||||||
|
px: 3,
|
||||||
|
py: 0.85,
|
||||||
|
minHeight: 42,
|
||||||
|
...(variant === "primary"
|
||||||
|
? {
|
||||||
|
background: "linear-gradient(135deg, #315397, #7bc0ff)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "1px solid rgba(255,255,255,0.28)",
|
||||||
|
boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
|
||||||
|
boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: "rgba(255,255,255,0.84)",
|
||||||
|
color: "#315397",
|
||||||
|
border: "1px solid rgba(49,83,151,0.26)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "rgba(255,255,255,0.95)",
|
||||||
|
borderColor: "rgba(49,83,151,0.46)",
|
||||||
|
boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
className="glass-dashboard"
|
||||||
sx={{
|
sx={{
|
||||||
backgroundImage: "url('https://source.unsplash.com/random/1600x900?technology')",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
display: "flex",
|
position: "relative",
|
||||||
alignItems: "center",
|
px: { xs: 2, md: 6 },
|
||||||
justifyContent: "center",
|
pt: { xs: 8, md: 12 },
|
||||||
|
pb: { xs: 8, md: 10 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="xs">
|
<Box className="glass-orb orb-1" />
|
||||||
<Card
|
<Box className="glass-orb orb-2" />
|
||||||
sx={{
|
<Box className="glass-orb orb-3" />
|
||||||
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>
|
|
||||||
|
|
||||||
{token ? (
|
<Grid
|
||||||
<>
|
container
|
||||||
<Button
|
spacing={4}
|
||||||
variant="contained"
|
alignItems="center"
|
||||||
color="primary"
|
justifyContent="center"
|
||||||
onClick={() => navigate("/admin/dashboard")}
|
sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}
|
||||||
sx={{ mt: 3, width: "100%" }}
|
>
|
||||||
|
<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 d’application 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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LockOutlinedIcon fontSize="large" />
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{ fontWeight: 700, color: "#0b1a3d" }}
|
||||||
|
>
|
||||||
|
{token ? "Bienvenue dans l’espace 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%" }}
|
||||||
>
|
>
|
||||||
Accéder au tableau de bord
|
<Stack spacing={2}>
|
||||||
</Button>
|
<TextField
|
||||||
<Button
|
label="Nom d’utilisateur"
|
||||||
variant="contained"
|
fullWidth
|
||||||
color="error"
|
variant="outlined"
|
||||||
onClick={handleLogout}
|
value={username}
|
||||||
sx={{ mt: 2, width: "100%" }}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
>
|
required
|
||||||
Déconnexion
|
sx={{
|
||||||
</Button>
|
"& .MuiOutlinedInput-root": {
|
||||||
</>
|
borderRadius: 999,
|
||||||
) : (
|
backgroundColor: "rgba(255,255,255,0.95)",
|
||||||
<form onSubmit={handleLogin}>
|
},
|
||||||
<TextField
|
}}
|
||||||
label="Nom d'utilisateur"
|
/>
|
||||||
fullWidth
|
<TextField
|
||||||
margin="normal"
|
label="Mot de passe d’application"
|
||||||
variant="outlined"
|
fullWidth
|
||||||
value={username}
|
variant="outlined"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
type="password"
|
||||||
required
|
value={appPassword}
|
||||||
/>
|
onChange={(e) => setAppPassword(e.target.value)}
|
||||||
<TextField
|
required
|
||||||
label="Mot de passe d'application"
|
sx={{
|
||||||
fullWidth
|
"& .MuiOutlinedInput-root": {
|
||||||
margin="normal"
|
borderRadius: 999,
|
||||||
variant="outlined"
|
backgroundColor: "rgba(255,255,255,0.95)",
|
||||||
type="password"
|
},
|
||||||
value={appPassword}
|
}}
|
||||||
onChange={(e) => setAppPassword(e.target.value)}
|
/>
|
||||||
required
|
{error ? (
|
||||||
/>
|
<Typography color="error" variant="body2">
|
||||||
<Button
|
{error}
|
||||||
type="submit"
|
</Typography>
|
||||||
variant="contained"
|
) : null}
|
||||||
color="primary"
|
<Button
|
||||||
sx={{ mt: 3, width: "100%" }}
|
type="submit"
|
||||||
>
|
variant="contained"
|
||||||
Se connecter
|
disabled={loading}
|
||||||
</Button>
|
sx={{ ...composeButtonSx("primary"), width: "100%" }}
|
||||||
</form>
|
>
|
||||||
)}
|
{loading ? (
|
||||||
</CardContent>
|
<CircularProgress size={20} sx={{ color: "#ffffff" }} />
|
||||||
</Card>
|
) : (
|
||||||
</Container>
|
"Se connecter"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,19 +43,27 @@ const Posts = () => {
|
|||||||
return html.replace(/(<([^>]+)>)/gi, ""); // Supprime toutes les balises HTML
|
return html.replace(/(<([^>]+)>)/gi, ""); // Supprime toutes les balises HTML
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decodeHtml = (html) => {
|
||||||
|
const txt = document.createElement("textarea");
|
||||||
|
txt.innerHTML = html;
|
||||||
|
return txt.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Fonction pour tronquer un texte à 100 caractères max
|
// Fonction pour tronquer un texte à 100 caractères max
|
||||||
const truncateText = (text, maxLength) => {
|
const truncateText = (text, maxLength) => {
|
||||||
const strippedText = stripHtmlTags(text
|
const parser = new DOMParser();
|
||||||
.replace(/<[^>]*>/g, "") // Supprime les balises HTML
|
const decoded = parser.parseFromString(text, "text/html").body.textContent || "";
|
||||||
.replace(/ /g, " ") // Remplace les espaces non bris
|
|
||||||
); // Nettoyage HTML
|
const cleanText = decoded.replace(/ /g, " "); // Optionnel si besoin
|
||||||
return strippedText.length > maxLength ? `${strippedText.substring(0, maxLength)}...` : strippedText;
|
return cleanText.length > maxLength ? `${cleanText.substring(0, maxLength)}...` : cleanText;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ padding: "40px 20px" }}>
|
<Box sx={{ padding: "40px 20px" }}>
|
||||||
<Typography variant="h2" sx={{ fontWeight: "bold", textAlign: "center", mb: 4, mt:5 }}>
|
<Typography variant="h1" sx={{ fontWeight: "bold", fontSize: "3em", color:"#315397", textAlign: "center", mb: 4, mt:5 }}>
|
||||||
Nos Articles
|
L'actualité du numérique
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Grid container spacing={4} justifyContent="center">
|
<Grid container spacing={4} justifyContent="center">
|
||||||
@@ -64,7 +72,7 @@ const Posts = () => {
|
|||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
"&:hover": { backgroundColor: "#0e467f", color: "white", transform: "scale(1.05)", boxShadow: 6 },
|
"&:hover": { backgroundColor: "#0e467f", color: "#ffffff", transform: "scale(1.05)", boxShadow: 6 },
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate(`/post/${post.slug}`)}
|
onClick={() => navigate(`/post/${post.slug}`)}
|
||||||
>
|
>
|
||||||
@@ -72,7 +80,7 @@ const Posts = () => {
|
|||||||
<CardMedia component="img" height="200" image={post.image} alt={post.title.rendered} />
|
<CardMedia component="img" height="200" image={post.image} alt={post.title.rendered} />
|
||||||
)}
|
)}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
|
<Typography variant="h2" sx={{ fontWeight: "bold", fontSize: "1.5em", color:"#315397"}}>
|
||||||
{post.title.rendered}
|
{post.title.rendered}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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;
|
||||||
@@ -1,63 +1,109 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import ServicePageTemplate from "../ServicePageTemplate";
|
import ServicePageTemplate from "../ServicePageTemplate";
|
||||||
|
import { getPageById } from "../../wordpress";
|
||||||
const ServiceDeux = () => {
|
const ServiceDeux = () => {
|
||||||
const serviceDetails = {
|
const [acfData, setAcfData] = useState(null);
|
||||||
// Hero
|
const [loading, setLoading] = useState(true);
|
||||||
title: "Structure béton charpente métallique & bois",
|
const pageId = 671; // 🔥 Remplace par l'ID réel de la page WordPress
|
||||||
subtitle: "Dominez l’intelligence artificielle.",
|
|
||||||
|
|
||||||
image: "https://picsum.photos/id/239/1920/1080.webp",
|
useEffect(() => {
|
||||||
ctaText: "Découvrir la formation IA",
|
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",
|
ctaLink: "/contact",
|
||||||
|
|
||||||
// section 1
|
// section 1
|
||||||
interestTitle: "Béton ou Charpente :\n Quel Matériau Choisir pour une Construction Durable ?", // ✅ Modifiable individuellement
|
interestTitle: acfData?.second_titre_beton ||"Béton ou Charpente :\n Quel Matériau Choisir pour une Construction Durable ?", // ✅ Modifiable individuellement
|
||||||
description: "Le béton est le matériau de construction le plus répandu dans le monde. \nIl est présent dans tous les secteurs de la construction.\n\n Longtemps associé à l’image 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. \n\nIl commence même à entrer dans les foyers comme produit de décoration !\nUne 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.",
|
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é à l’image 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
|
// section 2
|
||||||
desirTitle: "Ce que nous offrons",// ✅ Modifiable individuellement
|
desirTitle: acfData?.troisieme_titre_beton || "Titre",// ✅ Modifiable individuellement
|
||||||
|
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
title: "Fondamentaux de l’IA",
|
title: acfData?.titre_carte_one_beton || "Titre !",
|
||||||
description: "Concepts, algorithmes et applications.",
|
description: acfData?.description_carte_one_beton || "Description.",
|
||||||
modalText:
|
modalText: acfData?.description_carte_one_beton || "Description Modal.",
|
||||||
"Découvrez les bases de l’IA, des algorithmes aux applications concrètes dans divers domaines.",
|
|
||||||
modalImage: "https://picsum.photos/id/200/800/400.webp",
|
modalImage: "https://picsum.photos/id/200/800/400.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Python pour l’IA",
|
title: acfData?.titre_carte_two_beton || "Titre !",
|
||||||
description: "Bibliothèques populaires : TensorFlow, PyTorch.",
|
description: acfData?.description_carte_two_beton || "Description.",
|
||||||
modalText:
|
modalText: acfData?.description_carte_two_beton || "Description Modal.",
|
||||||
"Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles d’IA puissants.",
|
|
||||||
modalImage: "https://picsum.photos/id/201/800/400.webp",
|
modalImage: "https://picsum.photos/id/201/800/400.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Projets Pratiques",
|
title: acfData?.titre_carte_tree_beton || "Titre !",
|
||||||
description: "Créez vos propres modèles IA.",
|
description: acfData?.description_carte_tree_beton || "Description.",
|
||||||
modalText:
|
modalText: acfData?.description_carte_tree_beton || "Description Modal.",
|
||||||
"Mettez vos compétences en pratique en développant des projets IA réels.",
|
|
||||||
modalImage: "https://picsum.photos/id/202/800/400.webp",
|
modalImage: "https://picsum.photos/id/202/800/400.webp",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
carouselItems: [
|
carouselItems: [
|
||||||
{
|
{
|
||||||
title: "Site E-commerce",
|
title: "Structure beton",
|
||||||
description: "Un projet e-commerce performant et moderne.",
|
description: "Un projet e-commerce performant et moderne.",
|
||||||
image: "https://picsum.photos/id/453/800/400.webp",
|
image: "https://picsum.photos/id/522/800/400.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Portfolio Personnel",
|
title: "Structure bois",
|
||||||
description: "Montrez vos compétences avec un portfolio sur mesure.",
|
description: "Montrez vos compétences avec un portfolio sur mesure.",
|
||||||
image: "https://picsum.photos/id/454/800/400.webp",
|
image: "https://picsum.photos/id/521/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",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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;
|
||||||
@@ -7,7 +7,8 @@ const ServiceTrois = () => {
|
|||||||
title: "Électricité", // ✅ Modifiable individuellement
|
title: "Électricité", // ✅ Modifiable individuellement
|
||||||
subtitle: "IN3 est un bureau d’études technique spécialisé dans l’ingénierie électrique du bâtiment.",
|
subtitle: "IN3 est un bureau d’études technique spécialisé dans l’ingénierie électrique du bâtiment.",
|
||||||
|
|
||||||
image: "https://picsum.photos/id/1018/1920/1080.webp",
|
image: "https://picsum.photos/id/1019/1920/1080.webp",
|
||||||
|
heroBackground: "linear-gradient(135deg,#0c101f 0%,#03050b 85%)",
|
||||||
ctaText: "En savoir plus",
|
ctaText: "En savoir plus",
|
||||||
ctaLink: "/contact",
|
ctaLink: "/contact",
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const ServiceUn = () => {
|
|||||||
subtitle:
|
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 l’ouvrage, de diriger l’exécution des marchés de travaux, de proposer le règlement des travaux et leur réception. Nos missions sont les suivantes :", // ✅ Modifiable individuellement
|
"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 l’ouvrage, de diriger l’exé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/1018/1920/1080.webp",
|
image: "https://picsum.photos/id/1026/1920/1080.webp",
|
||||||
|
heroBackground: "linear-gradient(135deg,#0b1428 0%,#16223f 40%,#04070f 100%)",
|
||||||
ctaText: "En savoir plus",
|
ctaText: "En savoir plus",
|
||||||
ctaLink: "/contact",
|
ctaLink: "/contact",
|
||||||
|
|
||||||
@@ -48,14 +49,14 @@ const ServiceUn = () => {
|
|||||||
|
|
||||||
carouselItems: [
|
carouselItems: [
|
||||||
{
|
{
|
||||||
title: "Portfolio Modern",
|
title: "préstation maitrise d'oeuvre",
|
||||||
description: "Montrez vos compétences avec un portfolio unique.",
|
description: "Montrez vos compétences avec un portfolio unique.",
|
||||||
image: "https://picsum.photos/id/305/800/400.webp",
|
image: "https://picsum.photos/id/307/800/400.webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Blog SEO",
|
title: "Bureau d'études maitrise-oeuvre",
|
||||||
description: "Optimisez vos articles pour le référencement.",
|
description: "Optimisez vos articles pour le référencement.",
|
||||||
image: "https://picsum.photos/id/306/800/400.webp",
|
image: "https://picsum.photos/id/308/800/400.webp",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// ✅ Métadonnées SEO
|
// ✅ Métadonnées SEO
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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 d’automatisation 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;
|
||||||
@@ -4,7 +4,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
||||||
const PostDetails = () => {
|
const PostDetails = () => {
|
||||||
const { slug } = useParams(); // ✅ Utilisation correcte du slug
|
const { slug } = useParams();
|
||||||
const [post, setPost] = useState(null);
|
const [post, setPost] = useState(null);
|
||||||
const [image, setImage] = useState(null);
|
const [image, setImage] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -18,16 +18,12 @@ const PostDetails = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`📢 Requête API pour le slug : ${slug}`);
|
console.log(`📢 Requête API pour le slug : ${slug}`);
|
||||||
|
|
||||||
// ✅ Requête API correcte
|
|
||||||
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?slug=${slug}&_fields=id,title,content,featured_media,date`);
|
||||||
|
|
||||||
if (response.data.length > 0) {
|
if (response.data.length > 0) {
|
||||||
const article = response.data[0]; // ✅ Récupération du premier article trouvé
|
const article = response.data[0];
|
||||||
setPost(article);
|
setPost(article);
|
||||||
console.log("✅ Article trouvé :", article);
|
|
||||||
|
|
||||||
// ✅ Récupération de l'image en vedette si elle existe
|
|
||||||
if (article.featured_media) {
|
if (article.featured_media) {
|
||||||
console.log(`📢 Récupération de l'image ID : ${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}`);
|
const mediaResponse = await api.get(`wp/v2/media/${article.featured_media}`);
|
||||||
@@ -42,7 +38,7 @@ const PostDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPost();
|
fetchPost();
|
||||||
}, [slug]); // ✅ Dépendance correcte
|
}, [slug]);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
|
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
|
||||||
@@ -50,12 +46,10 @@ const PostDetails = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ padding: "40px 20px", display: "flex", flexDirection: "column" }}>
|
<Box sx={{ padding: "40px 20px", display: "flex", flexDirection: "column" }}>
|
||||||
{/* ✅ Bouton Retour */}
|
|
||||||
<Button onClick={() => navigate(-1)} variant="contained" sx={{ mt: 5, mb: 3, maxWidth: "200px" }}>
|
<Button onClick={() => navigate(-1)} variant="contained" sx={{ mt: 5, mb: 3, maxWidth: "200px" }}>
|
||||||
⬅ Retour
|
⬅ Retour
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* ✅ Titre de l'article */}
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="h1"
|
variant="h1"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -68,17 +62,27 @@ const PostDetails = () => {
|
|||||||
{post.title.rendered}
|
{post.title.rendered}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* ✅ Image + Contenu */}
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "40px 20px", margin: "auto" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "40px 20px", margin: "auto" }}>
|
||||||
{image && (
|
{image && (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={image}
|
src={image}
|
||||||
alt={post.title.rendered}
|
alt={post.title.rendered}
|
||||||
sx={{ width: "100%", maxHeight: "400px", objectFit: "cover", borderRadius: "8px", mb: 3 }}
|
sx={{ width: "100%", maxHeight: "400px", objectFit: "cover", borderRadius: "10px 0 10px 10px", mb: 3 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Typography variant="body1" sx={{ maxWidth: "800px", mx: "auto" }} dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
|
|
||||||
|
<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 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
+248
-44
@@ -1,71 +1,275 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import axios from "axios";
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
const SEO = ({
|
const SEO = ({
|
||||||
postId,
|
pageUrl,
|
||||||
defaultTitle = "Titre par défaut",
|
defaultTitle = "Titre par défaut",
|
||||||
defaultDescription = "Description par défaut",
|
defaultDescription = "Description par défaut",
|
||||||
defaultCanonicalUrl = "",
|
defaultCanonicalUrl = "",
|
||||||
defaultOgImage = "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
|
defaultOgImage = DEFAULT_OG_IMAGE,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [rankMathHead, setRankMathHead] = useState(null);
|
||||||
|
const [shouldFallback, setShouldFallback] = useState(true);
|
||||||
|
|
||||||
const [metaData, setMetaData] = useState({
|
const runtimeUrl = getWindowHref();
|
||||||
title: defaultTitle,
|
const rankMathTargetUrl = pageUrl || defaultCanonicalUrl || runtimeUrl;
|
||||||
description: defaultDescription,
|
const normalizedPageUrl = normalizeToProduction(pageUrl);
|
||||||
canonicalUrl: defaultCanonicalUrl,
|
const resolvedCanonical =
|
||||||
ogTitle: defaultTitle,
|
normalizeToProduction(defaultCanonicalUrl) ||
|
||||||
ogDescription: defaultDescription,
|
normalizedPageUrl ||
|
||||||
ogImage: defaultOgImage,
|
normalizeToProduction(runtimeUrl) ||
|
||||||
});
|
PRODUCTION_ORIGIN;
|
||||||
|
const targetUrl =
|
||||||
|
normalizedPageUrl ||
|
||||||
|
resolvedCanonical ||
|
||||||
|
normalizeToProduction(rankMathTargetUrl) ||
|
||||||
|
PRODUCTION_ORIGIN;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!postId) return;
|
if (!rankMathTargetUrl) return;
|
||||||
|
|
||||||
const fetchMetaData = async () => {
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchRankMathHead = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(
|
const { data } = await api.get("rankmath/v1/getHead", {
|
||||||
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${postId}`
|
params: { url: rankMathTargetUrl },
|
||||||
);
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (data && data.rank_math_og) {
|
if (
|
||||||
setMetaData((prev) => ({
|
data?.success &&
|
||||||
...prev,
|
data.head &&
|
||||||
title: data.rank_math_og.og_title || prev.title,
|
typeof window !== "undefined" &&
|
||||||
description: data.rank_math_og.og_description || prev.description,
|
window.DOMParser
|
||||||
ogTitle: data.rank_math_og.og_title || prev.title,
|
) {
|
||||||
ogDescription: data.rank_math_og.og_description || prev.description,
|
const parser = new window.DOMParser();
|
||||||
ogImage: data.rank_math_og.og_image || prev.ogImage,
|
const parsedDocument = parser.parseFromString(
|
||||||
canonicalUrl: data.acf?.canonical_url || window.location.href
|
`<!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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
setRankMathHead({
|
||||||
|
title: headElement.querySelector("title")?.textContent || "",
|
||||||
|
meta: metaTags,
|
||||||
|
links: linkTags,
|
||||||
|
scripts: scriptTags,
|
||||||
|
});
|
||||||
|
setShouldFallback(false);
|
||||||
|
} else {
|
||||||
|
setRankMathHead(null);
|
||||||
|
setShouldFallback(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("⚠️ Erreur lors de la récupération des métadonnées SEO :", 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMetaData();
|
fetchRankMathHead();
|
||||||
}, [postId]);
|
|
||||||
|
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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Helmet>
|
<Helmet>
|
||||||
{/* SEO standard */}
|
{!shouldFallback && rankMathNodes}
|
||||||
<title>{metaData.title}</title>
|
|
||||||
<meta name="description" content={metaData.description} />
|
|
||||||
{metaData.canonicalUrl && <link rel="canonical" href={metaData.canonicalUrl} />}
|
|
||||||
|
|
||||||
{/* Open Graph (Facebook, LinkedIn) */}
|
{shouldFallback && (
|
||||||
<meta property="og:type" content="website" />
|
<>
|
||||||
<meta property="og:title" content={metaData.ogTitle} />
|
<title>{defaultTitle}</title>
|
||||||
<meta property="og:description" content={metaData.ogDescription} />
|
<meta name="description" content={defaultDescription} />
|
||||||
<meta property="og:image" content={metaData.ogImage} />
|
<meta name="robots" content={DEFAULT_ROBOTS} />
|
||||||
<meta property="og:url" content={metaData.canonicalUrl} />
|
{resolvedCanonical && (
|
||||||
|
<link rel="canonical" href={resolvedCanonical} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Twitter Cards */}
|
<meta property="og:type" content="website" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta property="og:title" content={defaultTitle} />
|
||||||
<meta name="twitter:title" content={metaData.ogTitle} />
|
<meta property="og:description" content={defaultDescription} />
|
||||||
<meta name="twitter:description" content={metaData.ogDescription} />
|
<meta property="og:image" content={defaultOgImage} />
|
||||||
<meta name="twitter:image" content={metaData.ogImage} />
|
{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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo, isValidElement, useEffect } from "react";
|
||||||
import { Box, Typography, Button, Grid, Card, CardContent, Modal, Fade, Backdrop, CircularProgress } from "@mui/material";
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Modal,
|
||||||
|
Fade,
|
||||||
|
Backdrop,
|
||||||
|
CircularProgress,
|
||||||
|
} from "@mui/material";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
@@ -7,6 +18,13 @@ import "swiper/css/navigation";
|
|||||||
import "swiper/css/pagination";
|
import "swiper/css/pagination";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Helmet } from "react-helmet-async";
|
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 = ({
|
const ServicePageTemplate = ({
|
||||||
title,
|
title,
|
||||||
@@ -16,6 +34,7 @@ const ServicePageTemplate = ({
|
|||||||
desirTitle,
|
desirTitle,
|
||||||
features,
|
features,
|
||||||
image,
|
image,
|
||||||
|
heroBackground,
|
||||||
ctaText,
|
ctaText,
|
||||||
ctaLink,
|
ctaLink,
|
||||||
carouselItems,
|
carouselItems,
|
||||||
@@ -24,13 +43,88 @@ const ServicePageTemplate = ({
|
|||||||
const [openModal, setOpenModal] = useState(false);
|
const [openModal, setOpenModal] = useState(false);
|
||||||
const [modalContent, setModalContent] = useState({});
|
const [modalContent, setModalContent] = useState({});
|
||||||
const [loadingImage, setLoadingImage] = useState(true);
|
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) => {
|
const handleCardClick = (feature) => {
|
||||||
setModalContent({
|
setModalContent(buildModalPayload(feature));
|
||||||
title: feature.title,
|
|
||||||
text: feature.modalText || "Texte non disponible.",
|
|
||||||
image: feature.modalImage || "https://picsum.photos/id/43/800/400.webp",
|
|
||||||
});
|
|
||||||
setLoadingImage(true);
|
setLoadingImage(true);
|
||||||
setOpenModal(true);
|
setOpenModal(true);
|
||||||
};
|
};
|
||||||
@@ -40,7 +134,13 @@ const ServicePageTemplate = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ backgroundColor: "#f5f5f5", color: "#333", fontFamily: "Arial, sans-serif" }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
color: "#333",
|
||||||
|
fontFamily: "Arial, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* ✅ SEO META TAGS */}
|
{/* ✅ SEO META TAGS */}
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{seo?.metaTitle || title}</title>
|
<title>{seo?.metaTitle || title}</title>
|
||||||
@@ -59,10 +159,10 @@ const ServicePageTemplate = ({
|
|||||||
{/* Section Hero */}
|
{/* Section Hero */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mt:5,
|
mt: 5,
|
||||||
backgroundImage: `url(${image})`,
|
backgroundImage: heroBackgroundStyle,
|
||||||
backgroundSize: "cover",
|
backgroundSize: heroLoaded && image ? "cover, cover" : "cover",
|
||||||
backgroundPosition: "center",
|
backgroundPosition: heroLoaded && image ? "center, center" : "center",
|
||||||
minHeight: "70vh",
|
minHeight: "70vh",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -70,26 +170,47 @@ const ServicePageTemplate = ({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "black",
|
color: "black",
|
||||||
padding: "20px",
|
padding: "20px",
|
||||||
|
backgroundColor: heroLoaded ? undefined : "#020712",
|
||||||
|
transition: "background-image 0.8s ease, opacity 0.4s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{
|
<Box
|
||||||
maxWidth: "800px",
|
sx={{
|
||||||
backgroundColor:"rgba(255, 255, 255, 0.39)",
|
maxWidth: "800px",
|
||||||
p:5,
|
backgroundColor: "rgba(255, 255, 255, 0.39)",
|
||||||
borderRadius:5
|
p: 5,
|
||||||
}}>
|
borderRadius: 5,
|
||||||
<Typography variant="h1" sx={{ fontWeight: "bold", mb: 2, fontSize: { xs: "2rem", md: "3rem", lg: "3rem",whiteSpace: "pre-line" } }}>
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
mb: 2,
|
||||||
|
fontSize: {
|
||||||
|
xs: "2rem",
|
||||||
|
md: "3rem",
|
||||||
|
lg: "3rem",
|
||||||
|
whiteSpace: "pre-line",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ mb: 4 }}>
|
<Typography variant="body1" component="div" sx={{ mb: 4 }}>
|
||||||
{subtitle}
|
{renderRichText(subtitle) ?? subtitle ?? ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
href={ctaLink}
|
href={ctaLink}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="large"
|
size="large"
|
||||||
sx={{ textTransform: "none", fontWeight: "bold", backgroundColor: "#0e467f", "&:hover": { backgroundColor: "#00bcd4" } }}
|
sx={{
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: "bold",
|
||||||
|
backgroundColor: "#0e467f",
|
||||||
|
"&:hover": { backgroundColor: "#00bcd4" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{ctaText}
|
{ctaText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -97,18 +218,82 @@ const ServicePageTemplate = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section numero 1 */}
|
{/* Section numero 1 */}
|
||||||
<Box sx={{ padding: "40px 20px", textAlign: "center" }}>
|
<Box sx={{ fontWeight: "5", textAlign: "center" }}>
|
||||||
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, whiteSpace: "pre-line" }}>
|
<Typography
|
||||||
|
variant="h2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
mb: 7,
|
||||||
|
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
|
||||||
|
whiteSpace: "pre-line",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{interestTitle}
|
{interestTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ maxWidth: "1000px", margin: "0 auto", mb: 4, whiteSpace: "pre-line" }}>
|
{descriptionIsElement ? (
|
||||||
{description}
|
<Typography variant="body1" component="div">
|
||||||
</Typography>
|
{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>
|
</Box>
|
||||||
|
|
||||||
{/* Section numero 2 */}
|
{/* Section numero 2 */}
|
||||||
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
|
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
|
||||||
<Typography variant="h2" sx={{ fontWeight: "bold", textAlign: "center", mb: 4, fontSize: { xs: "2rem", md: "3rem", lg: "2rem" } }}>
|
<Typography
|
||||||
|
variant="h2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
mb: 4,
|
||||||
|
fontSize: { xs: "2rem", md: "3rem", lg: "2rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{desirTitle}
|
{desirTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -127,39 +312,80 @@ const ServicePageTemplate = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2, fontSize: 26,whiteSpace: "pre-line" }}>
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
mb: 2,
|
||||||
|
fontSize: 26,
|
||||||
|
whiteSpace: "pre-line",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#555" }}>
|
<Typography variant="body2" sx={{ color: "#555" }}>
|
||||||
{feature.description}
|
{getPreviewText(feature)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<Modal open={openModal} onClose={handleCloseModal} closeAfterTransition BackdropComponent={Backdrop} BackdropProps={{ timeout: 500 }}>
|
<Modal
|
||||||
|
open={openModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
closeAfterTransition
|
||||||
|
BackdropComponent={Backdrop}
|
||||||
|
BackdropProps={{ timeout: 500 }}
|
||||||
|
>
|
||||||
<Fade in={openModal}>
|
<Fade in={openModal}>
|
||||||
<Box sx={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: "90%", maxWidth: "600px", p: 4, textAlign: "center", background: "white", borderRadius: 2, boxShadow: 3 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "90%",
|
||||||
|
maxWidth: "600px",
|
||||||
|
p: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
|
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
|
||||||
<img src={modalContent.image} alt={modalContent.title} style={{ width: "100%", borderRadius: "8px", display: loadingImage ? "none" : "block" }} onLoad={() => setLoadingImage(false)} />
|
<img
|
||||||
|
src={modalContent.image}
|
||||||
|
alt={modalContent.title}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: loadingImage ? "none" : "block",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingImage(false)}
|
||||||
|
/>
|
||||||
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
|
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||||
{modalContent.title}
|
{modalContent.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ mb: 4 }}>
|
<Typography variant="body1" sx={{ mb: 4 }} dangerouslySetInnerHTML={{ __html: modalContent.text }} >
|
||||||
{modalContent.text}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button onClick={handleCloseModal} variant="contained" color="secondary">
|
<Button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
Retour
|
Retour
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/* Carousel Section */}
|
{/* Carousel Section */}
|
||||||
{carouselItems && carouselItems.length > 0 && (
|
{carouselItems && carouselItems.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
padding: "40px 20px",
|
padding: "40px 20px",
|
||||||
@@ -167,11 +393,11 @@ const ServicePageTemplate = ({
|
|||||||
backgroundColor: "#f9f9f9",
|
backgroundColor: "#f9f9f9",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h2"
|
<Typography
|
||||||
|
variant="h2"
|
||||||
sx={{
|
sx={{
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
|
fontSize: { xs: "2rem", md: "3rem", lg: "3rem" }, // Responsive
|
||||||
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Découvrez nos réalisations
|
Découvrez nos réalisations
|
||||||
@@ -208,10 +434,11 @@ const ServicePageTemplate = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h3"
|
<Typography
|
||||||
|
variant="h3"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fontSize: { xs: '1.2rem', md: '2rem', lg: '2.5rem' }, // Responsive
|
fontSize: { xs: "1.2rem", md: "2rem", lg: "2.5rem" }, // Responsive
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -224,7 +451,6 @@ const ServicePageTemplate = ({
|
|||||||
</Swiper>
|
</Swiper>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -236,17 +462,32 @@ ServicePageTemplate.propTypes = {
|
|||||||
interestTitle: PropTypes.string.isRequired,
|
interestTitle: PropTypes.string.isRequired,
|
||||||
description: PropTypes.string.isRequired,
|
description: PropTypes.string.isRequired,
|
||||||
desirTitle: 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,
|
features: PropTypes.arrayOf(
|
||||||
image: PropTypes.string.isRequired,
|
PropTypes.shape({
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string.isRequired,
|
||||||
|
modalText: PropTypes.string,
|
||||||
|
modalImage: PropTypes.string,
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
image: PropTypes.string,
|
||||||
ctaText: PropTypes.string.isRequired,
|
ctaText: PropTypes.string.isRequired,
|
||||||
ctaLink: PropTypes.string.isRequired,
|
ctaLink: PropTypes.string.isRequired,
|
||||||
carouselItems: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string.isRequired, description: PropTypes.string.isRequired, image: 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
|
seo: PropTypes.object, // ✅ Ajout du SEO en option
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Valeurs par défaut
|
// ✅ Valeurs par défaut
|
||||||
ServicePageTemplate.defaultProps = {
|
ServicePageTemplate.defaultProps = {
|
||||||
carouselItems: [],
|
carouselItems: [],
|
||||||
|
heroBackground: "linear-gradient(135deg, #0a1930 0%, #020710 85%)",
|
||||||
seo: {}, // ✅ SEO par défaut vide
|
seo: {}, // ✅ SEO par défaut vide
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const TestimonialSection = () => {
|
|||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="https://picsum.photos/600/600.webp"
|
src="https://picsum.photos/600/600.webp"
|
||||||
alt="Illustration"
|
alt="Bureau d'études & Maîtrise d'oeuvre"
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -64,7 +64,7 @@ const TestimonialSection = () => {
|
|||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
|
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt="Marie Dupont"
|
alt="client bureau d'étude be-in3 le Mans(72)"
|
||||||
src="https://picsum.photos/100.webp"
|
src="https://picsum.photos/100.webp"
|
||||||
sx={{ width: 56, height: 56, marginRight: 2 }}
|
sx={{ width: 56, height: 56, marginRight: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Quill from "quill";
|
||||||
|
import "quill/dist/quill.snow.css";
|
||||||
|
|
||||||
|
const DEFAULT_TOOLBAR = [
|
||||||
|
[{ header: [1, 2, 3, false] }],
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["link", "blockquote", "code-block"],
|
||||||
|
["clean"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const RichTextEditor = ({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
placeholder = "Commencez à écrire…",
|
||||||
|
minHeight = 200,
|
||||||
|
toolbarOptions = DEFAULT_TOOLBAR,
|
||||||
|
sx = {},
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const quillRef = useRef(null);
|
||||||
|
const internalUpdateRef = useRef(false);
|
||||||
|
const latestOnChangeRef = useRef(onChange);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestOnChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!containerRef.current || quillRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quill = new Quill(containerRef.current, {
|
||||||
|
theme: "snow",
|
||||||
|
placeholder,
|
||||||
|
modules: {
|
||||||
|
toolbar: toolbarOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quillRef.current = quill;
|
||||||
|
|
||||||
|
const handleTextChange = () => {
|
||||||
|
if (internalUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = quill.root.innerHTML;
|
||||||
|
if (typeof latestOnChangeRef.current === "function") {
|
||||||
|
latestOnChangeRef.current(html);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
quill.on("text-change", handleTextChange);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
internalUpdateRef.current = true;
|
||||||
|
quill.clipboard.dangerouslyPasteHTML(value);
|
||||||
|
internalUpdateRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
quill.off("text-change", handleTextChange);
|
||||||
|
const toolbar = containerRef.current
|
||||||
|
? containerRef.current.previousSibling
|
||||||
|
: null;
|
||||||
|
if (toolbar && toolbar.classList?.contains("ql-toolbar")) {
|
||||||
|
toolbar.remove();
|
||||||
|
}
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
quillRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [placeholder]); // initialise une fois (placeholder rarement modifié)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const quill = quillRef.current;
|
||||||
|
if (!quill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedValue = value || "";
|
||||||
|
if (normalizedValue === quill.root.innerHTML) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
internalUpdateRef.current = true;
|
||||||
|
quill.setContents(quill.clipboard.convert(normalizedValue));
|
||||||
|
internalUpdateRef.current = false;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
"& .ql-container": {
|
||||||
|
borderColor: "rgba(0,0,0,0.12)",
|
||||||
|
minHeight,
|
||||||
|
},
|
||||||
|
"& .ql-toolbar": {
|
||||||
|
borderColor: "rgba(0,0,0,0.12)",
|
||||||
|
},
|
||||||
|
"& .ql-editor": {
|
||||||
|
minHeight,
|
||||||
|
},
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={containerRef} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichTextEditor;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const MOODLE_API_URL = import.meta.env.VITE_MOODLE_API_URL || "";
|
||||||
|
export const MOODLE_TOKEN = import.meta.env.VITE_MOODLE_TOKEN || "";
|
||||||
|
|
||||||
|
export const isMoodleConfigured = () =>
|
||||||
|
Boolean(MOODLE_API_URL && MOODLE_TOKEN);
|
||||||
+39
-13
@@ -9,17 +9,25 @@ import NotFound from "./components/Pages/NotFound.jsx";
|
|||||||
import GestionArticles from "./components/Pages/GestionArticles";
|
import GestionArticles from "./components/Pages/GestionArticles";
|
||||||
import EditPost from "./components/EditPost";
|
import EditPost from "./components/EditPost";
|
||||||
import GestionPageAccueil from "./components/Pages/GestionPageAccueil";
|
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
|
// Lazy loading des pages
|
||||||
const Home = lazy(() => import('./components/Pages/Home.jsx'));
|
const Home = lazy(() => import("./components/Pages/Home.jsx"));
|
||||||
const About = lazy(() => import('./components/Pages/About'));
|
const About = lazy(() => import("./components/Pages/About"));
|
||||||
const Contact = lazy(() => import('./components/Pages/Contact'));
|
const Contact = lazy(() => import("./components/Pages/Contact"));
|
||||||
const Post = lazy(() => import('./components/Pages/Post'));
|
const Post = lazy(() => import("./components/Pages/Post"));
|
||||||
const PostDetails = lazy(() => import('./components/PostDetails')) ;
|
const PostDetails = lazy(() => import("./components/PostDetails"));
|
||||||
const Search = lazy(() => import('./components/Pages/Search')); // Nouveau composant pour la recherche
|
const Search = lazy(() => import("./components/Pages/Search")); // Nouveau composant pour la recherche
|
||||||
const BureauEtude = lazy(() => import('./components/Pages/BureauEtude'));
|
const BureauEtude = lazy(() => import("./components/Pages/BureauEtude"));
|
||||||
const CreatePost = lazy(() => import('./components/Pages/CreatePost'));
|
const CreatePost = lazy(() => import("./components/Pages/CreatePost"));
|
||||||
const PostList = lazy(() => import("./components/Pages/PostList"));
|
const PostList = lazy(() => import("./components/Pages/PostList"));
|
||||||
const Login = lazy(() => import("./components/Pages/Login"));
|
const Login = lazy(() => import("./components/Pages/Login"));
|
||||||
const Dashboard = lazy(() => import("./components/Pages/Dashboard"));
|
const Dashboard = lazy(() => import("./components/Pages/Dashboard"));
|
||||||
@@ -28,10 +36,12 @@ const Dashboard = lazy(() => import("./components/Pages/Dashboard"));
|
|||||||
import ServiceUn from "./components/Pages/ServiceUn.jsx";
|
import ServiceUn from "./components/Pages/ServiceUn.jsx";
|
||||||
import ServiceDeux from "./components/Pages/ServiceDeux.jsx";
|
import ServiceDeux from "./components/Pages/ServiceDeux.jsx";
|
||||||
import ServiceTrois from "./components/Pages/ServiceTrois.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 des composants globaux
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Footer from './components/Footer';
|
import Footer from "./components/Footer";
|
||||||
|
|
||||||
function YourApp() {
|
function YourApp() {
|
||||||
return (
|
return (
|
||||||
@@ -42,14 +52,23 @@ function YourApp() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/posts" element={<Post />} />
|
<Route path="/posts" element={<Post />} />
|
||||||
<Route path="/admin/edit-post/:id" element={<EditPost />} />
|
<Route path="/admin/edit-post/:id" element={<EditPost />} />
|
||||||
<Route path="/bureauEtude" element={<BureauEtude />} />
|
<Route path="/nosFormations" element={<BureauEtude />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/contact" element={<Contact />} />
|
<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 */}
|
{/* Routes pour les services */}
|
||||||
<Route path="/services/prestation-maitrise-oeuvre" element={<ServiceUn />} />
|
<Route
|
||||||
<Route path="/services/structure-beton-charpente-metallique-bois" element={<ServiceDeux />} />
|
path="/services/prestation-maitrise-oeuvre"
|
||||||
|
element={<ServiceUn />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/services/formations-web"
|
||||||
|
element={<ServiceDeux />}
|
||||||
|
/>
|
||||||
<Route path="/services/electricite" element={<ServiceTrois />} />
|
<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="/admin/create-post" element={<CreatePost />} />
|
||||||
<Route path="/post/:slug" element={<PostDetails />} />
|
<Route path="/post/:slug" element={<PostDetails />} />
|
||||||
<Route path="/admin/posts" element={<PostList />} />
|
<Route path="/admin/posts" element={<PostList />} />
|
||||||
@@ -58,6 +77,13 @@ function YourApp() {
|
|||||||
<Route path="/admin/gestion-articles" element={<GestionArticles />} />
|
<Route path="/admin/gestion-articles" element={<GestionArticles />} />
|
||||||
<Route path="/admin/gestion-page-accueil" element={<GestionPageAccueil />} />
|
<Route path="/admin/gestion-page-accueil" element={<GestionPageAccueil />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<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>
|
</Routes>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { MOODLE_API_URL, MOODLE_TOKEN } from "../config/moodle";
|
||||||
|
|
||||||
|
const ensureConfig = () => {
|
||||||
|
if (!MOODLE_API_URL || !MOODLE_TOKEN) {
|
||||||
|
throw new Error(
|
||||||
|
"Configuration Moodle manquante. Vérifiez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendParam = (searchParams, value, key) => {
|
||||||
|
if (value === undefined || value === null || key === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((entry, index) => {
|
||||||
|
appendParam(searchParams, entry, `${key}[${index}]`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && !(value instanceof Date)) {
|
||||||
|
Object.entries(value).forEach(([childKey, childValue]) => {
|
||||||
|
const nextKey = key ? `${key}[${childKey}]` : childKey;
|
||||||
|
appendParam(searchParams, childValue, nextKey);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedValue =
|
||||||
|
value instanceof Date ? value.getTime().toString() : String(value);
|
||||||
|
searchParams.append(key, normalizedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callMoodle = async (wsfunction, payload = {}, options = {}) => {
|
||||||
|
ensureConfig();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("wstoken", MOODLE_TOKEN);
|
||||||
|
params.append("wsfunction", wsfunction);
|
||||||
|
params.append("moodlewsrestformat", "json");
|
||||||
|
|
||||||
|
Object.entries(payload).forEach(([key, value]) =>
|
||||||
|
appendParam(params, value, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await axios.post(MOODLE_API_URL, params, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout: options.timeout ?? 20000,
|
||||||
|
...options.axios,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.exception || response.data?.errorcode) {
|
||||||
|
const message =
|
||||||
|
response.data.message ||
|
||||||
|
response.data.debuginfo ||
|
||||||
|
response.data.errorcode;
|
||||||
|
throw new Error(message || "Erreur Moodle inconnue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MoodleApi = {
|
||||||
|
listCourses: (searchValue = "") => {
|
||||||
|
if (searchValue) {
|
||||||
|
return callMoodle("core_course_get_courses_by_field", {
|
||||||
|
field: "search",
|
||||||
|
value: searchValue,
|
||||||
|
}).then((data) => data.courses || []);
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_get_courses").then(
|
||||||
|
(data) => data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getCourseById: async (courseId) => {
|
||||||
|
const response = await callMoodle("core_course_get_courses_by_field", {
|
||||||
|
field: "id",
|
||||||
|
value: Number(courseId),
|
||||||
|
});
|
||||||
|
return response?.courses?.[0] || null;
|
||||||
|
},
|
||||||
|
createCourse: (coursePayload) => {
|
||||||
|
const normalized = {
|
||||||
|
summaryformat: 1,
|
||||||
|
format: "topics",
|
||||||
|
...coursePayload,
|
||||||
|
};
|
||||||
|
if (normalized.categoryid !== undefined) {
|
||||||
|
normalized.categoryid = Number(normalized.categoryid);
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_create_courses", {
|
||||||
|
courses: [normalized],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateCourse: (coursePayload) => {
|
||||||
|
const normalized = {
|
||||||
|
summaryformat: 1,
|
||||||
|
...coursePayload,
|
||||||
|
};
|
||||||
|
if (normalized.id !== undefined) {
|
||||||
|
normalized.id = Number(normalized.id);
|
||||||
|
}
|
||||||
|
if (normalized.categoryid !== undefined) {
|
||||||
|
normalized.categoryid = Number(normalized.categoryid);
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_update_courses", {
|
||||||
|
courses: [normalized],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteCourses: (courseIds) =>
|
||||||
|
callMoodle("core_course_delete_courses", {
|
||||||
|
courseids: courseIds,
|
||||||
|
}),
|
||||||
|
getCourseContents: (courseId) =>
|
||||||
|
callMoodle("core_course_get_contents", { courseid: Number(courseId) }),
|
||||||
|
getPagesByCourse: (courseId) =>
|
||||||
|
callMoodle("mod_page_get_pages_by_courses", {
|
||||||
|
courseids: [Number(courseId)],
|
||||||
|
}).then((data) => data.pages || []),
|
||||||
|
getAssignmentsByCourse: (courseId) =>
|
||||||
|
callMoodle("mod_assign_get_assignments", {
|
||||||
|
courseids: [Number(courseId)],
|
||||||
|
}).then((data) => {
|
||||||
|
const normalizedId = Number(courseId);
|
||||||
|
const course = data?.courses?.find((c) => c.id === normalizedId);
|
||||||
|
return course?.assignments || [];
|
||||||
|
}),
|
||||||
|
getH5PActivitiesByCourse: (courseId) =>
|
||||||
|
callMoodle("mod_h5pactivity_get_h5pactivities_by_courses", {
|
||||||
|
courseids: [Number(courseId)],
|
||||||
|
}).then((data) => data.h5pactivities || []),
|
||||||
|
getQuizzesByCourse: (courseId) =>
|
||||||
|
callMoodle("mod_quiz_get_quizzes_by_courses", {
|
||||||
|
courseids: [Number(courseId)],
|
||||||
|
}).then((data) => data.quizzes || []),
|
||||||
|
createSection: (sectionPayload) =>
|
||||||
|
callMoodle("core_course_create_sections", {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
summaryformat: 1,
|
||||||
|
...sectionPayload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
updateSection: (sectionPayload) => {
|
||||||
|
const payload = {
|
||||||
|
summaryformat: 1,
|
||||||
|
...sectionPayload,
|
||||||
|
};
|
||||||
|
if (payload.id !== undefined) {
|
||||||
|
payload.id = Number(payload.id);
|
||||||
|
}
|
||||||
|
if (payload.courseid !== undefined) {
|
||||||
|
payload.courseid = Number(payload.courseid);
|
||||||
|
}
|
||||||
|
if (payload.section !== undefined && payload.section !== null) {
|
||||||
|
const normalizedSection = Number(payload.section);
|
||||||
|
payload.section = Number.isNaN(normalizedSection)
|
||||||
|
? undefined
|
||||||
|
: normalizedSection;
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_update_sections", {
|
||||||
|
sections: [payload],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteSections: (courseId, sectionIds, sectionNumbers) => {
|
||||||
|
const payload = {
|
||||||
|
courseid: Number(courseId),
|
||||||
|
};
|
||||||
|
if (sectionIds?.length) {
|
||||||
|
payload.sectionids = sectionIds.map((id) => Number(id));
|
||||||
|
}
|
||||||
|
if (sectionNumbers?.length) {
|
||||||
|
payload.sectionnumbers = sectionNumbers.map((value) => Number(value));
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_delete_sections", payload);
|
||||||
|
},
|
||||||
|
moveModuleToSection: (moduleId, sectionNumber) => {
|
||||||
|
const payload = {
|
||||||
|
cmid: Number(moduleId),
|
||||||
|
};
|
||||||
|
if (sectionNumber !== null && sectionNumber !== undefined) {
|
||||||
|
const normalized = Number(sectionNumber);
|
||||||
|
if (!Number.isNaN(normalized)) {
|
||||||
|
payload.section = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callMoodle("core_course_edit_module", payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoodleApi;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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, "");
|
||||||
|
};
|
||||||
+251
-79
@@ -2,6 +2,7 @@ import axios from "axios";
|
|||||||
import { getToken } from "./auth"; // 🔥 Import du token pour l'authentification
|
import { getToken } from "./auth"; // 🔥 Import du token pour l'authentification
|
||||||
|
|
||||||
const API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2";
|
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
|
* 🔹 Upload une image et retourne son ID
|
||||||
@@ -37,6 +38,39 @@ export async function uploadImage(file) {
|
|||||||
throw error;
|
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 l’image 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() {
|
export async function fetchPosts() {
|
||||||
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/posts");
|
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/posts");
|
||||||
@@ -52,33 +86,29 @@ export async function fetchPosts() {
|
|||||||
* @returns {Promise<Object>} - Le post créé
|
* @returns {Promise<Object>} - Le post créé
|
||||||
*/
|
*/
|
||||||
export async function createPost(title, content, imageId) {
|
export async function createPost(title, content, imageId) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) throw new Error("Utilisateur non authentifié.");
|
||||||
console.error("❌ Aucun token trouvé !");
|
|
||||||
throw new Error("Utilisateur non authentifié.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const response = await axios.post(
|
||||||
const response = await axios.post(`${API_URL}/posts`, {
|
`${API_URL}/posts`,
|
||||||
title,
|
{
|
||||||
content,
|
title,
|
||||||
status: "publish",
|
content,
|
||||||
featured_media: imageId,
|
status: "publish",
|
||||||
}, {
|
featured_media: imageId, // 🔥 très important
|
||||||
headers: {
|
},
|
||||||
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
|
{
|
||||||
"Content-Type": "application/json"
|
headers: {
|
||||||
}
|
"Authorization": `Basic ${token}`,
|
||||||
});
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
console.log("✅ Post créé avec succès :", response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Erreur création post :", error.response?.data || error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔹 Récupère un article WordPress par son ID
|
* 🔹 Récupère un article WordPress par son ID
|
||||||
* @param {number} postId - L'ID du post à récupérer
|
* @param {number} postId - L'ID du post à récupérer
|
||||||
@@ -102,76 +132,87 @@ export async function getPostById(postId) {
|
|||||||
* @param {string} content - Le nouveau contenu
|
* @param {string} content - Le nouveau contenu
|
||||||
* @returns {Promise<Object>} - L'article mis à jour
|
* @returns {Promise<Object>} - L'article mis à jour
|
||||||
*/
|
*/
|
||||||
export async function updatePost(postId, title, content) {
|
export async function updatePost(postId, title, content, imageId = null) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error("❌ Aucun token trouvé !");
|
console.error("❌ Aucun token trouvé !");
|
||||||
throw new Error("Utilisateur non authentifié.");
|
throw new Error("Utilisateur non authentifié.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const dataToSend = {
|
||||||
const response = await axios.post(`${API_URL}/posts/${postId}`, { // ✅ Rester sur `POST`
|
title,
|
||||||
title,
|
content,
|
||||||
content,
|
status: "publish",
|
||||||
status: "publish",
|
};
|
||||||
}, {
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ Article mis à jour avec succès :", response.data);
|
if (imageId) {
|
||||||
return response.data;
|
dataToSend.featured_media = imageId; // 🔥 Ajoute l'image en vedette
|
||||||
} catch (error) {
|
}
|
||||||
console.error("❌ Erreur mise à jour article :", error.response?.data || error.message);
|
|
||||||
throw error;
|
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
|
* 🔹 Supprime un article WordPress et son image associée
|
||||||
* @param {number} postId - L'ID de l'article à supprimer
|
* @param {number} postId - L'ID de l'article à supprimer
|
||||||
* @param {number} imageId - L'ID de l'image en vedette à supprimer (facultatif)
|
* @param {number} imageId - L'ID de l'image en vedette à supprimer (facultatif)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function deletePost(postId, imageId) {
|
export async function deletePost(postId) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) throw new Error("Utilisateur non authentifié.");
|
||||||
console.error("❌ Aucun token trouvé !");
|
|
||||||
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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
} catch (error) {
|
||||||
console.log(`📢 Suppression article ID: ${postId}...`);
|
console.error("❌ Erreur suppression article ou image :", error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
// ✅ Suppression du post
|
}
|
||||||
await axios.delete(`${API_URL}/posts/${postId}?force=true`, {
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`✅ Article ${postId} supprimé.`);
|
|
||||||
|
|
||||||
// ✅ Suppression de l'image associée si elle existe
|
|
||||||
if (imageId) {
|
|
||||||
console.log(`📢 Suppression image ID: ${imageId}...`);
|
|
||||||
await axios.delete(`${API_URL}/media/${imageId}?force=true`, {
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`✅ Image ${imageId} supprimée.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Erreur suppression :", error.response?.data || error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔹 Met à jour les champs ACF d'une page WordPress
|
* 🔹 Met à jour les champs ACF d'une page WordPress
|
||||||
* @param {number} pageId - L'ID de la page à modifier
|
* @param {number} pageId - L'ID de la page à modifier
|
||||||
@@ -212,3 +253,134 @@ export async function updateHomePageACF(pageId, newData) {
|
|||||||
throw 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`,
|
||||||
|
{
|
||||||
|
objectType: "post", // ✅ Les pages WordPress sont du type "post".
|
||||||
|
objectID: objectId,
|
||||||
|
meta: sanitizedMeta,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ Métadonnées Rank Math mises à jour :", response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Erreur mise à jour Rank Math :", error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4949
File diff suppressed because one or more lines are too long
+21
-23
@@ -4,25 +4,27 @@ import viteCompression from 'vite-plugin-compression';
|
|||||||
import { visualizer } from 'rollup-plugin-visualizer';
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
// Plugin React
|
// ✅ Plugin React
|
||||||
react(),
|
react(),
|
||||||
// Compression Brotli
|
|
||||||
|
// ✅ Compression Brotli & Gzip fusionnées
|
||||||
viteCompression({
|
viteCompression({
|
||||||
algorithm: 'brotliCompress',
|
algorithm: 'brotliCompress', // Utilisation de Brotli en priorité
|
||||||
ext: '.br',
|
ext: '.br',
|
||||||
threshold: 10240, // Fichiers supérieurs à 10 Ko seront compressés
|
threshold: 10240, // Compression à partir de 10 Ko
|
||||||
}),
|
}),
|
||||||
// Compression Gzip
|
|
||||||
viteCompression({
|
viteCompression({
|
||||||
algorithm: 'gzip',
|
algorithm: 'gzip', // Compression Gzip en fallback
|
||||||
ext: '.gz',
|
ext: '.gz',
|
||||||
threshold: 10240,
|
threshold: 10240,
|
||||||
}),
|
}),
|
||||||
// Visualiseur de bundle
|
|
||||||
|
// ✅ Visualisation des bundles (ouvre `stats.html` après le build)
|
||||||
visualizer({
|
visualizer({
|
||||||
open: true, // Ouvre automatiquement le fichier généré après le build
|
open: false, // Mets `true` si tu veux ouvrir le fichier automatiquement
|
||||||
filename: 'stats.html',
|
filename: 'stats.html',
|
||||||
template: 'treemap',
|
template: 'treemap',
|
||||||
}),
|
}),
|
||||||
@@ -30,7 +32,6 @@ export default defineConfig({
|
|||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// Définit les alias pour des chemins simplifiés
|
|
||||||
'@pages': path.resolve(__dirname, './src/pages'),
|
'@pages': path.resolve(__dirname, './src/pages'),
|
||||||
'@components': path.resolve(__dirname, './src/components'),
|
'@components': path.resolve(__dirname, './src/components'),
|
||||||
'@assets': path.resolve(__dirname, './src/assets'),
|
'@assets': path.resolve(__dirname, './src/assets'),
|
||||||
@@ -39,18 +40,17 @@ export default defineConfig({
|
|||||||
|
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 1000,
|
chunkSizeWarningLimit: 1000,
|
||||||
target: 'esnext', // Cible des navigateurs modernes
|
target: 'esnext', // ✅ Meilleure compatibilité avec les navigateurs modernes
|
||||||
minify: 'esbuild', // Minification avec esbuild pour une meilleure performance
|
minify: 'esbuild', // ✅ Utilisation d'Esbuild pour un build rapide
|
||||||
sourcemap: false, // Désactive les sourcemaps en production (activez pour dev si nécessaire)
|
sourcemap: true, // ✅ Active les source maps en production (utile pour le debug)
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
// Création de chunks manuels pour optimiser les performances
|
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
if (id.includes('@mui')) {
|
if (id.includes('@mui')) {
|
||||||
return 'vendor_mui'; // Chunk spécifique à MUI
|
return 'vendor_mui'; // ✅ Sépare MUI du reste des dépendances
|
||||||
}
|
}
|
||||||
return 'vendor'; // Autres dépendances
|
return 'vendor'; // ✅ Sépare les dépendances globales
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -58,18 +58,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: 3000, // Définit le port du serveur de développement
|
port: 3000, // ✅ Serveur sur le port 3000
|
||||||
open: true, // Ouvre automatiquement le navigateur au démarrage
|
open: true, // ✅ Ouvre le navigateur au démarrage
|
||||||
cors: true, // Autorise toutes les requêtes CORS
|
cors: true, // ✅ Active les requêtes CORS
|
||||||
},
|
},
|
||||||
|
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: [
|
||||||
'swiper/react', // Inclut swiper/react dans les dépendances optimisées
|
'swiper/react',
|
||||||
'swiper/modules', // Inclut swiper/modules dans les dépendances optimisées
|
'swiper/modules',
|
||||||
],
|
|
||||||
exclude: [
|
|
||||||
'cssnano', // Exclut les dépendances problématiques comme cssnano
|
|
||||||
],
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,28 @@
|
|||||||
|
#
|
||||||
|
# Backend configuration for contact form emails.
|
||||||
|
# Copy this file to `.env` and fill in your SMTP details.
|
||||||
|
#
|
||||||
|
|
||||||
|
# SMTP server host (e.g. smtp.gmail.com, smtp.mailgun.org, etc.)
|
||||||
|
SMTP_HOST=mail.sebvtl.com
|
||||||
|
|
||||||
|
# SMTP port (465 for SSL, 587 for TLS)
|
||||||
|
SMTP_PORT=465
|
||||||
|
|
||||||
|
# Use "true" if your provider requires SSL (port 465)
|
||||||
|
# Otherwise leave to "false" for TLS (port 587)
|
||||||
|
SMTP_SECURE=true
|
||||||
|
|
||||||
|
# SMTP credentials
|
||||||
|
SMTP_USER=contact@sebvtl.com
|
||||||
|
SMTP_PASS=Lightwave9.0**
|
||||||
|
|
||||||
|
# Optional: override the recipient (defaults to sebastien@octopusdesign.fr)
|
||||||
|
CONTACT_RECIPIENT=sebastien@octopusdesign.fr
|
||||||
|
|
||||||
|
# Optional: override the "from" address visible in the email client
|
||||||
|
CONTACT_FROM=no-reply@octopusdesign.fr
|
||||||
|
|
||||||
|
# Optional: add extra allowed origins for CORS (comma separated)
|
||||||
|
# Example: CORS_ALLOWED_ORIGINS=https://preprod.octopusdesign.fr,https://admin.octopusdesign.fr
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import express from "express";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const buildTransporterConfig = () => {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = Number(process.env.SMTP_PORT || 587);
|
||||||
|
const secure =
|
||||||
|
typeof process.env.SMTP_SECURE === "string"
|
||||||
|
? process.env.SMTP_SECURE === "true"
|
||||||
|
: port === 465;
|
||||||
|
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user && pass) {
|
||||||
|
baseConfig.auth = { user, pass };
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post("/contact", async (req, res) => {
|
||||||
|
const { name, email, subject, message, consent } = req.body || {};
|
||||||
|
|
||||||
|
console.info("📨 Requête contact reçue", {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
consent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
"Merci de compléter tous les champs requis avant d'envoyer votre message.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient =
|
||||||
|
process.env.CONTACT_RECIPIENT || "sebastien@octopusdesign.fr";
|
||||||
|
const fromAddress =
|
||||||
|
process.env.CONTACT_FROM ||
|
||||||
|
process.env.SMTP_FROM ||
|
||||||
|
process.env.SMTP_USER ||
|
||||||
|
"no-reply@octopusdesign.fr";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = nodemailer.createTransport(buildTransporterConfig());
|
||||||
|
|
||||||
|
const mailSubject = subject.trim()
|
||||||
|
? `[Site Octopus Design] ${subject.trim()}`
|
||||||
|
: "Nouveau message depuis le site Octopus Design";
|
||||||
|
|
||||||
|
const consentLabel = consent ? "✅ Consentement donné" : "❌ Consentement non fourni";
|
||||||
|
|
||||||
|
const textBody = `
|
||||||
|
Nom : ${name}
|
||||||
|
E-mail : ${email}
|
||||||
|
Consentement : ${consentLabel}
|
||||||
|
|
||||||
|
Message :
|
||||||
|
${message}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<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;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
@@ -0,0 +1,979 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,3 +290,35 @@ add_action('rest_api_init', function () {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
Reference in New Issue
Block a user