54 Commits

Author SHA1 Message Date
sebvtl728 ff5d08ed72 refactor: remove Bilans component and related styles to clean up project structure 2026-06-22 10:45:58 +02:00
sebvtl728 9d8e4e6314 chore: update dependency tree metadata and refine API endpoint structure 2026-05-18 17:04:53 +02:00
sebvtl728 ccc2e1347d refactor: update bundle data and integrate new tool management API endpoints 2026-05-05 23:16:36 +02:00
sebvtl728 3fabebeb85 feat: integrate Moodle API services, add RichTextEditor component, and configure environment variables for course management. 2026-04-09 16:37:09 +02:00
sebvtl728 a264f9b430 mise a jour structure visuel BO 2025-11-06 11:28:07 +01:00
sebvtl728 1890d830e1 mise a jour structure SEO 2025-11-05 01:08:39 +01:00
sebvtl728 5909871dec add tools 2025-11-02 21:36:00 +01:00
sebvtl728 f242bcc5d6 MAJ gestion des pages services 2025-10-26 15:38:17 +01:00
sebvtl728 82406fb046 maj majeur opengraph et page accueil 2025-10-26 03:52:24 +01:00
sebvtl728 f4cd293805 maj majeur opengraph et page accueil 2025-10-26 03:24:39 +01:00
sebvtl728 e7e5c5e3fd integration SEORank 2025-10-26 01:32:28 +02:00
sebvtl728 718eb8353b integration bilans 2025-10-26 00:30:16 +02:00
sebvtl728 a04cc47e26 maj css 2025-07-31 23:27:23 +02:00
sebvtl728 04aa793815 update octopus 2025-07-31 23:06:48 +02:00
sebvtl728 53ae6365f4 Menu octopus 2025-07-31 22:25:04 +02:00
sebvtl728 58ce0af44e update fonction de texte 2025-07-31 21:17:42 +02:00
sebvtl728 88582a89b5 add option de texte 2025-07-31 20:49:20 +02:00
sebvtl728 7c01c6e81d bug affichage post 2025-07-31 20:15:17 +02:00
sebvtl728 51195ac398 maj css 2025-07-31 20:01:45 +02:00
sebvtl728 fb77ca86f9 maj css 2025-07-31 19:28:58 +02:00
sebvtl728 e267591ebb update edit post cloudinary 2025-07-31 18:25:36 +02:00
sebvtl728 2ee6c53a98 correction bug cloudinary 2025-07-31 17:06:15 +02:00
sebvtl728 bfe25828fe creation backend fonction import img cloudinary 2025-07-31 16:39:14 +02:00
sebvtl728 f43e285a9c gestion des images articles cloudinary 2025-07-31 12:29:54 +02:00
sebvtl728 b02a509839 add filtre chapitre 2025-07-21 23:48:50 +02:00
sebvtl728 db8984b971 maj liste cours 2025-07-20 23:48:08 +02:00
sebvtl728 61222e2133 creation coursLecture 2025-07-19 19:24:57 +02:00
sebvtl728 9f5424c89b securité cours + correction bug accueil 2025-07-19 11:27:08 +02:00
sebvtl728 f7e8cfe938 maj listCours add function (page et devoir) 2025-07-18 23:45:08 +02:00
sebvtl728 98530922ef import image cloudinary 2025-07-18 15:54:08 +02:00
sebvtl728 77a7cda77f Gestion de cours 2025-07-18 13:28:10 +02:00
sebvtl728 885922483b accueil Page bug affichage correct ok 2025-07-16 22:08:59 +02:00
sebvtl728 38bafc63f6 accueil full ok 2025-07-16 01:48:25 +02:00
sebvtl728 5432b19604 MAJ fonction BO page accueil et correction de beug mission correctif css 2025-07-15 09:02:42 +02:00
sebvtl728 1042eecfd2 MAJ fonction BO page accueil et correction de beug 2025-07-15 00:40:20 +02:00
sebvtl728 f0e31c4639 Gestion bureau d'études 2025-02-18 21:56:18 +01:00
sebvtl728 94c215a253 MAJ 18022024 2025-02-18 18:35:24 +01:00
sebvtl728 26ecadd238 MAJ PAGES SERVICES DEUX 2025-02-16 22:03:08 +01:00
sebvtl728 a5ee040361 MAJ PAGES SERVICES DEUX 2025-02-16 21:56:51 +01:00
sebvtl728 f93932a038 MAJ PAGES SERVICES TCE 2025-02-16 18:58:48 +01:00
sebvtl728 55c3b64733 MAJ PAGES SERVICES 2025-02-16 17:59:18 +01:00
sebvtl728 31d57e365a MAJ HEADER 2025-02-11 17:55:25 +01:00
sebvtl728 b7a2831ee3 Mise en ligne serveur dist 2025-02-11 17:49:29 +01:00
sebvtl728 8094e445d4 Gestion de page ACF maj fonctionnel 2025-02-11 11:35:41 +01:00
sebvtl728 7df66f13df Gestion de page ACF maj 2025-02-11 00:38:29 +01:00
sebvtl728 038dd3498a Gestion de page ACF maj 2025-02-10 01:30:48 +01:00
sebvtl728 4a4d7104ac Gestion de page ACF 2025-02-10 00:52:15 +01:00
sebvtl728 d1cee7b3ae Gestion de page ACF 2025-02-09 19:27:56 +01:00
sebvtl728 20c2e3500d MAJ Référencement 2025-02-04 21:56:54 +01:00
sebvtl728 709221a0c1 Optimisation Expertises 2025-02-04 21:41:12 +01:00
sebvtl728 d8395f158d MAJ accordeon auto page home 2025-02-04 17:35:10 +01:00
sebvtl728 9ceb17a6f4 add accordeon auto page home 2025-02-04 17:05:12 +01:00
sebvtl728 b24600acd2 Maj des Pages services 2025-02-03 12:26:49 +01:00
sebvtl728 27a2ffdfb2 maj page about 2025-02-03 09:10:15 +01:00
67 changed files with 21739 additions and 1584 deletions
Vendored
BIN
View File
Binary file not shown.
+28
View File
@@ -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 lAPI de contact.
Suivez ces étapes pour activer lenvoi de-mails:
1. **Configuration backend**
- Copiez `server/cloudinary-backend/.env.example` vers `server/cloudinary-backend/.env`.
- Renseignez vos identifiants SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, etc.).
- Facultatif : ajustez `CONTACT_RECIPIENT`, `CONTACT_FROM`, ou ajoutez des domaines supplémentaires dans `CORS_ALLOWED_ORIGINS`.
2. **Installer et lancer le serveur contact**
```bash
cd server/cloudinary-backend
npm install
npm start
```
Par défaut, lAPI est disponible sur `http://localhost:3001/api/contact`.
3. **Configuration du front-end**
- Copiez `frontend/.env.local.example` vers `frontend/.env.local`.
- Ajustez `VITE_CONTACT_API_URL` si nécessaire (par défaut `http://localhost:3001/api/contact` en dev).
- Relancez lapplication React :
```bash
cd frontend
npm run dev
```
En production, assurez-vous que lURL définie dans `VITE_CONTACT_API_URL` pointe vers votre API accessible publiquement (ex. `https://octopusdesign.fr/api/contact`) et que le serveur backend est déployé avec les mêmes variables denvironnement SMTP.
+3
View File
@@ -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
+2
View File
@@ -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/*
+17
View File
@@ -6,3 +6,20 @@ Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react](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
View File
@@ -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 doeuvre</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&apos;appuie sur React et nécessite JavaScript pour l&apos;expérience complète.
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
utilisez ces liens directs&nbsp;:
</p>
<ul class="no-js-nav">
<li><a href="/">Accueil</a></li>
<li><a href="/services/prestation-maitrise-oeuvre/">Création de site web</a></li>
<li><a href="/services/formations-web/">Formations web</a></li>
<li><a href="/services/electricite/">Graphisme</a></li>
<li><a href="/services/service-securite-incendie/">Podcast</a></li>
<li><a href="/services/expertises-tce/">Formations professionnelles</a></li>
<li><a href="/nosFormations/">Nos formations</a></li>
<li><a href="/posts/">Actualités</a></li>
<li><a href="/contact/">Contact</a></li>
<li><a href="/search/">Recherche</a></li>
<li><a href="/sitemap.xml">Plan du site complet</a></li>
</ul>
<p>
Retrouvez aussi nos informations légales et politiques via le plan du site.
</p>
</section>
</noscript>
<script type="module" src="/src/main.jsx" defer></script>
</body> </body>
</html> </html>
+1243 -297
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -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 -4
View File
@@ -1,5 +1,2 @@
User-agent: * User-agent: *
Disallow: /static/ Disallow: /
Allow: /
Sitemap: https://octopusdesign.fr/sitemap.xml
+149
View File
@@ -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);
});
+70
View File
@@ -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";
+50
View File
@@ -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

+38
View File
@@ -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
} */
+374
View File
@@ -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;
+107 -47
View File
@@ -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={{
+508 -88
View File
@@ -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 larticle
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Ajustez votre contenu, remplacez le visuel mis en avant et publiez
les mises à jour directement sur le site Octopus.
</Typography>
</Stack>
</Stack>
<Grid container spacing={3.5}>
<Grid item xs={12} lg={7}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardContent sx={{ p: 0 }}>
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: "wrap" }}
>
<Chip
icon={<History fontSize="small" />}
label={
postMeta.modified
? `Modifié le ${new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(postMeta.modified))}`
: "Modification en cours"
}
sx={{
bgcolor: "rgba(255,255,255,0.78)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
label={
postMeta.status === "publish"
? "Publiée"
: postMeta.status === "draft"
? "Brouillon"
: postMeta.status || "Statut inconnu"
}
color={
postMeta.status === "publish"
? "success"
: postMeta.status === "draft"
? "warning"
: "default"
}
sx={{ fontWeight: 600 }}
/>
</Stack>
{loading ? (
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Chargement de larticle
</Typography>
) : (
<Box component="form" onSubmit={handleUpdate} noValidate>
<TextField
label="Titre de l'article"
fullWidth
variant="outlined"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<RichTextEditor
value={content}
onChange={setContent}
minHeight={280}
toolbarOptions={toolbarOptions}
/>
</Box>
<Stack spacing={2.5}>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Remplacer le visuel
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Importez une nouvelle image optimisée pour vos
contenus.
</Typography>
<Box sx={{ mt: 2 }}>
<ImageUploaderCloudinary
onUploadSuccess={(url) => {
setImageUrl(url);
convertUrlToFile(url).then(setImageFile);
}}
/>
</Box>
</Box>
<Divider
sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }}
/>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Sélectionner depuis la médiathèque
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 2 }}
>
Choisissez un visuel existant dans Cloudinary.
</Typography>
<CloudinaryGallerySelector
onSelect={async (url) => {
setImageUrl(url);
const file = await convertUrlToFile(url);
setImageFile(file);
}}
/>
</Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ pt: 1 }}
>
<Button
type="submit"
variant="contained"
startIcon={<Publish />}
disabled={!canSubmit || submitting}
sx={{
...composeButtonSx("primary"),
minWidth: 220,
}}
>
{submitting ? "Enregistrement…" : "Mettre à jour"}
</Button>
<Button
variant="contained"
sx={composeButtonSx("outline")}
onClick={() => {
setTitle("");
setContent("");
setImageUrl(null);
setImageFile(null);
}}
>
Réinitialiser
</Button>
</Stack>
</Stack>
</Box>
)}
</Stack>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
bgcolor: "rgba(123,192,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
}}
>
<ImageIcon />
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d", mb: 1 }}
>
Rappels éditoriaux
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)" }}
>
Vérifiez la cohérence des titres et sous-titres.<br />
Ajoutez des liens internes vers vos formations Octopus.<br />
Noubliez pas de relire la mise en forme dans laperçu
WordPress.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel illustrera larticle dans la liste des publications
et sur les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box> </Box>
); );
} }
+173 -196
View File
@@ -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;
+3 -2
View File
@@ -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
+8 -13
View File
@@ -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 lintroduction, la méthodologie et les expertises du
bureau détude pour garder des informations pertinentes et à jour.
</Typography>
</Stack>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Compétences listées
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.competencies}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Ajoutez des compétences pour valoriser lexpertise Octopus.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.expertises}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Développez vos expertises pour renforcer la crédibilité de léquipe.
</Typography>
</Card>
</Grid>
</Grid>
{error ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(240,91,107,0.35) !important" }}
>
<Typography color="error" sx={{ fontWeight: 600 }}>
{error}
</Typography>
</Card>
) : null}
{successMessage ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(123,192,255,0.45) !important" }}
>
<Typography sx={{ color: "#0b8043", fontWeight: 600 }}>
{successMessage}
</Typography>
</Card>
) : null}
<Box component="form" onSubmit={handleUpdateACF}>
<Stack spacing={3.5}>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Introduction
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Présentez la vision globale du bureau détude et son rôle
au sein des formations Octopus.
</Typography>
<Box
sx={{
borderRadius: 2,
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<RichTextEditor
value={acfFields.introduction || ""}
onChange={(value) => handleFieldChange("introduction", value)}
minHeight={220}
/>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }} />
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Méthodologie
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez en quelques lignes la manière dont le bureau
détude accompagne les clients et partenaires.
</Typography>
<TextField
fullWidth
value={acfFields.methodologie || ""}
onChange={(event) =>
handleFieldChange("methodologie", event.target.value)
}
multiline
minRows={3}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
</Box>
</Stack>
</CardContent>
</Card>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Liste des compétences
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Ajoutez les compétences clés qui démontrent lexpertise du
bureau détude. Chaque compétence peut contenir un titre et
une description détaillée.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("liste_des_competences").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Titre"
fullWidth
value={item.comp_titre}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_titre",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Description"
fullWidth
multiline
minRows={3}
value={item.comp_description}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_description",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("liste_des_competences", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Stack>
</Card>
))}
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("liste_des_competences", {
comp_titre: "",
comp_description: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une compétence
</Button>
</Stack>
</CardContent>
</Card>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez les expertises métiers ou sectorielles qui
distinguent votre bureau détude.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("expertises_specifiques").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Nom de lexpertise"
fullWidth
value={item.expertise_nom}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_nom",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Détails"
fullWidth
multiline
minRows={2}
value={item.expertise_details}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_details",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("expertises_specifiques", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Stack>
</Card>
))}
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("expertises_specifiques", {
expertise_nom: "",
expertise_details: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une expertise
</Button>
</Stack>
</CardContent>
</Card>
<Button
type="submit"
variant="contained"
startIcon={<Save />}
disabled={submitting}
sx={{ ...composeButtonSx("primary"), alignSelf: "flex-end", minWidth: 220 }}
>
{submitting ? "Enregistrement…" : "Enregistrer les modifications"}
</Button>
</Stack>
</Box>
</Box>
</Box>
);
}
export default EditPageEtude;
+483
View File
@@ -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 &quot;Modifier&quot; pour ajuster les contenus
de cette page service depuis léditeur ACF dédié.
</Typography>
</Stack>
<Button
startIcon={<Edit />}
variant="contained"
sx={composeButtonSx("outline", "small")}
onClick={() => navigate(`/admin/edit-page/${page.id}`)}
>
Modifier
</Button>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
</Box>
);
}
export default GestionPagesACF;
+71 -21
View File
@@ -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>
+3 -1
View File
@@ -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 nest pas encore fait
const FOLDER = "articles-octopus";
const ImageUploaderCloudinary = ({ onUploadSuccess }) => {
const handleUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append("upload_preset", UPLOAD_PRESET);
formData.append("folder", FOLDER);
const res = await fetch(`https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`, {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.secure_url) {
const transformedUrl = data.secure_url.replace("/upload/", "/upload/f_webp,w_800/");
onUploadSuccess(transformedUrl); // Ici on t'envoie lURL optimisée
} else {
alert("❌ Erreur lors de l'upload");
}
};
return <input type="file" accept="image/*" onChange={handleUpload} />;
};
export default ImageUploaderCloudinary;
+2 -2
View File
@@ -26,7 +26,7 @@ const About = () => {
{ {
icon: <BusinessIcon />, 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"
+76 -73
View File
@@ -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>
+45 -13
View File
@@ -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&#39;%C3%A9tudes%20Ing%C3%A9nieries%20Ma%C3%AEtrise%20d&#39;oeuvre!5e0!3m2!1sfr!2sfr!4v1736328111099!5m2!1sfr!2sfr%22%20width=%22600%22%20height=%22450%22%20style=%22border:0;%22%20allowfullscreen=%22%22%20loading=%22lazy%22%20referrerpolicy=%22no-referrer-when-downgrade%22" 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>
+426 -87
View File
@@ -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>&lt;h2&gt;</code>) pour
clarifier la structure.<br />
Ajoutez des appels à laction vers vos formations Octopus.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel sera utilisé comme image de couverture sur la liste
des articles et les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box> </Box>
); );
} }
+482 -107
View File
@@ -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 daccueil",
description: "Mettez à jour les blocs héros, témoignages et sections clés.",
navigateTo: "/admin/Gestion-Page-Accueil",
icon: <HomeIcon fontSize="inherit" />,
accent: "rgba(123,192,255,0.35)",
},
{
title: "Tools studio",
description: "Centralisez vos outils internes et automatisez les tâches.",
navigateTo: "/admin/tools",
icon: <BuildIcon fontSize="inherit" />,
accent: "rgba(255,214,150,0.32)",
},
{
title: "Articles",
description: "Rédigez, programmez et optimisez les publications du blog.",
navigateTo: "/admin/gestion-articles",
icon: <ArticleIcon fontSize="inherit" />,
accent: "rgba(255,172,236,0.28)",
},
{
title: "Pages services",
description: "Actualisez les contenus ACF pour chaque offre Octopus.",
navigateTo: "/admin/pages-acf",
icon: <SettingsSuggestIcon fontSize="inherit" />,
accent: "rgba(158,241,209,0.32)",
},
{
title: "Bureau d’étude",
description: "Administrez les informations du pôle bureau d’étude.",
navigateTo: "/admin/bureau-etude-acf",
icon: <InsightsIcon fontSize="inherit" />,
accent: "rgba(255,199,186,0.34)",
},
{
title: "Cours & formations",
description: "Gérez le catalogue Moodle et vos ressources pédagogiques.",
navigateTo: "/admin/liste-cours",
icon: <SchoolIcon fontSize="inherit" />,
accent: "rgba(178,219,255,0.34)",
},
];
const QUICK_ACTIONS = [
{
label: "Nouvel article",
hint: "Créez un contenu en un clic",
onClick: () => navigate("/admin/gestion-articles"),
},
{
label: "Synchroniser Moodle",
hint: "Actualisez vos cours et stats",
onClick: () => navigate("/admin/liste-cours"),
},
{
label: "Voir le site",
hint: "Ouvrir octopusdesign.fr",
onClick: () => window.open("https://www.octopusdesign.fr", "_blank"),
},
];
const KPI_CARDS = [
{
label: "Articles publiés",
value: "128",
delta: "+4 ce mois-ci",
},
{
label: "Cours actifs",
value: "36",
delta: "8 en mise à jour",
},
{
label: "Pages services",
value: "12",
delta: "2 révisions en cours",
},
];
return ( 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 à lensemble des outils de gestion : contenus éditoriaux,
pages services, ressources pédagogiques et automatisations.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<LogoutIcon />}
onClick={handleLogout}
sx={composeButtonSx("ghost")}
>
Déconnexion
</Button>
</Stack>
<Box
className="glass-panel hero-panel"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
mb: 4,
position: "relative",
overflow: "hidden",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={{ xs: 3, md: 4 }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
>
<Box sx={{ flex: 1 }}>
<Typography
variant="h5"
sx={{
color: "#0b1a3d",
fontWeight: 700,
mb: 1,
}}
>
Priorités du jour
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.7)", lineHeight: 1.6 }}
>
Suivez la progression des mises en ligne, synchronisez Moodle
et gardez un œil sur vos contenus essentiels.
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: 2, flexWrap: "wrap" }}
>
<Chip
icon={<AutoStoriesIcon fontSize="small" />}
label="Contenus pédagogiques à jour"
sx={{
bgcolor: "rgba(123,192,255,0.16)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
icon={<PublicIcon fontSize="small" />}
label="Octopusdesign.fr en production"
sx={{
bgcolor: "rgba(255,214,150,0.18)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Stack>
</Box>
<Divider
orientation="vertical"
flexItem
sx={{
display: { xs: "none", md: "block" },
borderColor: "rgba(11, 26, 61, 0.08)",
}}
/>
<Box
sx={{
flexBasis: { xs: "100%", md: "40%" },
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Typography
variant="subtitle2"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 700 }}
>
Actions rapides
</Typography>
<Stack spacing={1.2}>
{QUICK_ACTIONS.map((action) => (
<Button
key={action.label}
variant="contained"
onClick={action.onClick}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
...composeButtonSx("subtle", "large"),
justifyContent: "space-between",
width: "100%",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{action.label}
</Typography>
<Typography
variant="caption"
sx={{ color: "rgba(49,83,151,0.7)", fontWeight: 500 }}
>
{action.hint}
</Typography>
</Box>
</Button>
))}
</Stack>
</Box>
</Stack>
</Box>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
{KPI_CARDS.map((card) => (
<Grid item xs={12} sm={4} key={card.label}>
<Card
className="glass-subcard"
sx={{
borderRadius: 3,
p: 2.5,
boxShadow: "0 28px 34px -30px rgba(12, 29, 74, 0.52)",
}}
>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
{card.label.toUpperCase()}
</Typography>
<Typography
variant="h4"
sx={{
color: "#0b1a3d",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
{card.value}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{card.delta}
</Typography>
</Card>
</Grid>
))}
</Grid>
<Typography
variant="h6"
sx={{ color: "#ffffff", fontWeight: 700, mb: 2 }}
>
Espaces de gestion
</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
+505 -104
View File
@@ -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(/&nbsp;/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(/&nbsp;/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(/&nbsp;/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
+81 -29
View File
@@ -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>
+227 -81
View File
@@ -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 dapplication pour gérer les
contenus Octopus : articles, pages services, formations et plus
encore.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Authentification sécurisée"
sx={{
bgcolor: "rgba(255,255,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Accès réservé Octopus Team"
sx={{
bgcolor: "rgba(123,192,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} md={6}>
<Box
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
boxShadow: "0 32px 46px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={3} alignItems="center">
<Box
sx={{
width: 72,
height: 72,
borderRadius: "50%",
bgcolor: "rgba(49,83,151,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
boxShadow: "0 18px 28px -20px rgba(12, 29, 74, 0.45)",
}}
>
<LockOutlinedIcon fontSize="large" />
</Box>
<Typography
variant="h5"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
{token ? "Bienvenue dans lespace Octopus" : "Connexion"}
</Typography>
{token ? (
<Stack spacing={1.5} sx={{ width: "100%" }}>
<Button
variant="contained"
onClick={() => navigate("/admin/dashboard")}
sx={{ ...composeButtonSx("primary"), width: "100%" }}
>
Accéder au tableau de bord
</Button>
<Button
variant="contained"
onClick={handleLogout}
sx={{ ...composeButtonSx("outline"), width: "100%" }}
>
Déconnexion
</Button>
</Stack>
) : (
<Box
component="form"
onSubmit={handleLogin}
sx={{ width: "100%" }}
> >
Accéder au tableau de bord <Stack spacing={2}>
</Button> <TextField
<Button label="Nom dutilisateur"
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 dapplication"
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>
); );
} }
+19 -11
View File
@@ -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(/&nbsp;/g, " ") // Remplace les espaces non bris
); // Nettoyage HTML const cleanText = decoded.replace(/&nbsp;/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;
+78 -32
View File
@@ -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 lintelligence 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é à limage négative des grands ensembles, gris et vieillissants, le béton a réalisé des progrès spectaculaires ces dernières années, tant au niveau de ses performances techniques que des aspects esthétiques. \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é à limage négative des grands ensembles, gris et vieillissants, le béton a réalisé des progrès spectaculaires ces dernières années, tant au niveau de ses performances techniques que des aspects esthétiques.</p><p>Il commence même à entrer dans les foyers comme produit de décoration ! Une charpente est un assemblage de pièces de bois et/ou de métal, servant à soutenir ou couvrir des constructions et faisant partie de la toiture.</p>",
// section 2 // section 2
desirTitle: "Ce que nous offrons",// Modifiable individuellement desirTitle: acfData?.troisieme_titre_beton || "Titre",// Modifiable individuellement
features: [ features: [
{ {
title: "Fondamentaux de lIA", 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 lIA, 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 lIA", 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 dIA 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 lingénierie électrique du bâtiment.", subtitle: "IN3 est un bureau d’études technique spécialisé dans lingé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",
+6 -5
View File
@@ -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 louvrage, de diriger lexécution des marchés de travaux, de proposer le règlement des travaux et leur réception. Nos missions sont les suivantes :", // Modifiable individuellement "Notre bureau d’études in3 au Mans conçoit des bâtiments à construire ou à rénover selon le programme fourni par le maître de louvrage, de diriger lexécution des marchés de travaux, de proposer le règlement des travaux et leur réception. Nos missions sont les suivantes :", // Modifiable individuellement
image: "https://picsum.photos/id/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
+229
View File
@@ -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 dautomatisation Octopus.
</Typography>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Accès sécurisé"
sx={{
bgcolor: "rgba(255,255,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Applications internes Octopus"
sx={{
bgcolor: "rgba(123,192,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
<Grid container spacing={3} rowSpacing={9}>
{toolsApps.map((app) => (
<Grid item xs={12} md={6} lg={4} key={app.id}>
<Card
className="glass-card"
sx={{
borderRadius: 4,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
p: { xs: 2.5, md: 3 },
}}
>
<Stack spacing={1.8}>
<Typography
variant="overline"
sx={{ color: "rgba(11, 26, 61, 0.65)", letterSpacing: 1 }}
>
{app.external ? "Application externe" : "Module interne"}
</Typography>
<Typography
variant="h6"
sx={{ color: "#cfd2d8ff", fontWeight: 700 }}
>
{app.name}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
>
{app.description}
</Typography>
</Stack>
<Button
variant="contained"
sx={composeButtonSx("primary")}
onClick={() => handleOpenApp(app)}
>
Ouvrir
</Button>
</Card>
</Grid>
))}
</Grid>
</Box>
</Box>
);
}
export default Tools;
+16 -12
View File
@@ -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
View File
@@ -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>
); );
}; };
+294 -53
View File
@@ -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
}; };
+2 -2
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
export const MOODLE_API_URL = import.meta.env.VITE_MOODLE_API_URL || "";
export const MOODLE_TOKEN = import.meta.env.VITE_MOODLE_TOKEN || "";
export const isMoodleConfigured = () =>
Boolean(MOODLE_API_URL && MOODLE_TOKEN);
+39 -13
View File
@@ -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>
+193
View File
@@ -0,0 +1,193 @@
import axios from "axios";
import { MOODLE_API_URL, MOODLE_TOKEN } from "../config/moodle";
const ensureConfig = () => {
if (!MOODLE_API_URL || !MOODLE_TOKEN) {
throw new Error(
"Configuration Moodle manquante. Vérifiez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN."
);
}
};
const appendParam = (searchParams, value, key) => {
if (value === undefined || value === null || key === undefined) {
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => {
appendParam(searchParams, entry, `${key}[${index}]`);
});
return;
}
if (typeof value === "object" && !(value instanceof Date)) {
Object.entries(value).forEach(([childKey, childValue]) => {
const nextKey = key ? `${key}[${childKey}]` : childKey;
appendParam(searchParams, childValue, nextKey);
});
return;
}
const normalizedValue =
value instanceof Date ? value.getTime().toString() : String(value);
searchParams.append(key, normalizedValue);
};
export const callMoodle = async (wsfunction, payload = {}, options = {}) => {
ensureConfig();
const params = new URLSearchParams();
params.append("wstoken", MOODLE_TOKEN);
params.append("wsfunction", wsfunction);
params.append("moodlewsrestformat", "json");
Object.entries(payload).forEach(([key, value]) =>
appendParam(params, value, key)
);
const response = await axios.post(MOODLE_API_URL, params, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: options.timeout ?? 20000,
...options.axios,
});
if (response.data?.exception || response.data?.errorcode) {
const message =
response.data.message ||
response.data.debuginfo ||
response.data.errorcode;
throw new Error(message || "Erreur Moodle inconnue.");
}
return response.data;
};
export const MoodleApi = {
listCourses: (searchValue = "") => {
if (searchValue) {
return callMoodle("core_course_get_courses_by_field", {
field: "search",
value: searchValue,
}).then((data) => data.courses || []);
}
return callMoodle("core_course_get_courses").then(
(data) => data || []
);
},
getCourseById: async (courseId) => {
const response = await callMoodle("core_course_get_courses_by_field", {
field: "id",
value: Number(courseId),
});
return response?.courses?.[0] || null;
},
createCourse: (coursePayload) => {
const normalized = {
summaryformat: 1,
format: "topics",
...coursePayload,
};
if (normalized.categoryid !== undefined) {
normalized.categoryid = Number(normalized.categoryid);
}
return callMoodle("core_course_create_courses", {
courses: [normalized],
});
},
updateCourse: (coursePayload) => {
const normalized = {
summaryformat: 1,
...coursePayload,
};
if (normalized.id !== undefined) {
normalized.id = Number(normalized.id);
}
if (normalized.categoryid !== undefined) {
normalized.categoryid = Number(normalized.categoryid);
}
return callMoodle("core_course_update_courses", {
courses: [normalized],
});
},
deleteCourses: (courseIds) =>
callMoodle("core_course_delete_courses", {
courseids: courseIds,
}),
getCourseContents: (courseId) =>
callMoodle("core_course_get_contents", { courseid: Number(courseId) }),
getPagesByCourse: (courseId) =>
callMoodle("mod_page_get_pages_by_courses", {
courseids: [Number(courseId)],
}).then((data) => data.pages || []),
getAssignmentsByCourse: (courseId) =>
callMoodle("mod_assign_get_assignments", {
courseids: [Number(courseId)],
}).then((data) => {
const normalizedId = Number(courseId);
const course = data?.courses?.find((c) => c.id === normalizedId);
return course?.assignments || [];
}),
getH5PActivitiesByCourse: (courseId) =>
callMoodle("mod_h5pactivity_get_h5pactivities_by_courses", {
courseids: [Number(courseId)],
}).then((data) => data.h5pactivities || []),
getQuizzesByCourse: (courseId) =>
callMoodle("mod_quiz_get_quizzes_by_courses", {
courseids: [Number(courseId)],
}).then((data) => data.quizzes || []),
createSection: (sectionPayload) =>
callMoodle("core_course_create_sections", {
sections: [
{
summaryformat: 1,
...sectionPayload,
},
],
}),
updateSection: (sectionPayload) => {
const payload = {
summaryformat: 1,
...sectionPayload,
};
if (payload.id !== undefined) {
payload.id = Number(payload.id);
}
if (payload.courseid !== undefined) {
payload.courseid = Number(payload.courseid);
}
if (payload.section !== undefined && payload.section !== null) {
const normalizedSection = Number(payload.section);
payload.section = Number.isNaN(normalizedSection)
? undefined
: normalizedSection;
}
return callMoodle("core_course_update_sections", {
sections: [payload],
});
},
deleteSections: (courseId, sectionIds, sectionNumbers) => {
const payload = {
courseid: Number(courseId),
};
if (sectionIds?.length) {
payload.sectionids = sectionIds.map((id) => Number(id));
}
if (sectionNumbers?.length) {
payload.sectionnumbers = sectionNumbers.map((value) => Number(value));
}
return callMoodle("core_course_delete_sections", payload);
},
moveModuleToSection: (moduleId, sectionNumber) => {
const payload = {
cmid: Number(moduleId),
};
if (sectionNumber !== null && sectionNumber !== undefined) {
const normalized = Number(sectionNumber);
if (!Number.isNaN(normalized)) {
payload.section = normalized;
}
}
return callMoodle("core_course_edit_module", payload);
},
};
export default MoodleApi;
+48
View File
@@ -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
View File
@@ -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 limage sous forme de blob
const imageBlob = await fetch(imageUrl).then(res => res.blob());
const file = new File([imageBlob], "image-cloudinary.webp", { type: "image/webp" });
const formData = new FormData();
formData.append("file", file);
formData.append("title", "Image importée");
formData.append("status", "publish");
const response = await axios.post(`${API_URL}/media`, formData, {
headers: {
"Authorization": `Basic ${token}`,
"Content-Type": "multipart/form-data"
}
});
console.log("✅ Image uploadée depuis Cloudinary :", response.data);
return response.data.id;
} catch (error) {
console.error("❌ Erreur upload depuis Cloudinary :", error.response?.data || error.message);
throw error;
}
}
export async function fetchPosts() { 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
View File
File diff suppressed because one or more lines are too long
+21 -23
View File
@@ -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
], ],
}, },
}); });
BIN
View File
Binary file not shown.
Binary file not shown.
+28
View File
@@ -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;
+123
View File
@@ -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;
+39
View File
@@ -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
View File
@@ -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"
}
}
}
}
+16
View File
@@ -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);