maj majeur opengraph et page accueil
This commit is contained in:
+6
-6
@@ -8,15 +8,15 @@
|
|||||||
<!-- ✅ Ajout Open Graph statique -->
|
<!-- ✅ Ajout Open Graph statique -->
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
content="Titre par défaut - Centre de formations"
|
content="Centre de formations web IA"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Bienvenue sur notre Centre de formations aux métiers du numérique."
|
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:image"
|
property="og:image"
|
||||||
content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
|
content="https://res.cloudinary.com/dh5qgexjo/image/upload/v1753856912/centre-formation-metier-numerique_iwxjzv.avif"
|
||||||
/>
|
/>
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://ton-site.com" />
|
<meta property="og:url" content="https://ton-site.com" />
|
||||||
@@ -25,15 +25,15 @@
|
|||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:title"
|
name="twitter:title"
|
||||||
content="Titre par défaut - Centre de formations"
|
content="Centre de formations web IA"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Bienvenue sur notre Centre de formations aux métiers du numérique."
|
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée."
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:image"
|
name="twitter:image"
|
||||||
content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
|
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="/src/assets/vendor-BFTbvl5C.js" as="script"> -->
|
||||||
<link rel="preload" href="/assets/index-CfManBR6.css" as="style" />
|
<link rel="preload" href="/assets/index-CfManBR6.css" as="style" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -21,77 +21,42 @@ const ConstructSection = ({
|
|||||||
missionTitle,
|
missionTitle,
|
||||||
missionItems,
|
missionItems,
|
||||||
}) => {
|
}) => {
|
||||||
// État pour gérer l'ouverture des accordéons
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [missions, setMissions] = useState([]);
|
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" },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// ✅ Chargement initial des missions depuis localStorage ou WordPress
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMissions = async () => {
|
const hydrateMissions = async () => {
|
||||||
|
if (Array.isArray(missionItems) && missionItems.length) {
|
||||||
|
setMissions(missionItems);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 1️⃣ Vérifier si localStorage contient des missions
|
const response = await api.get("wp/v2/pages/13?_fields=acf");
|
||||||
const storedMissions = JSON.parse(localStorage.getItem("missions"));
|
const fetched = response.data.acf?.mission;
|
||||||
|
if (Array.isArray(fetched) && fetched.length) {
|
||||||
if (storedMissions && storedMissions.length > 0) {
|
setMissions(fetched);
|
||||||
setMissions(storedMissions);
|
|
||||||
console.log("✅ Missions chargées depuis localStorage !");
|
|
||||||
} else {
|
} else {
|
||||||
// 2️⃣ Si localStorage est vide, récupérer les missions depuis WordPress
|
setMissions(defaultMissions);
|
||||||
const response = await api.get("wp/v2/pages/13?_fields=acf");
|
|
||||||
|
|
||||||
if (
|
|
||||||
response.data.acf?.mission &&
|
|
||||||
Array.isArray(response.data.acf.mission)
|
|
||||||
) {
|
|
||||||
setMissions(response.data.acf.mission);
|
|
||||||
localStorage.setItem(
|
|
||||||
"missions",
|
|
||||||
JSON.stringify(response.data.acf.mission)
|
|
||||||
); // Sauvegarde en local
|
|
||||||
console.log("✅ Missions chargées depuis WordPress !");
|
|
||||||
} else {
|
|
||||||
// 3️⃣ Si aucune mission dans WordPress, utiliser des valeurs par défaut
|
|
||||||
const defaultMissions = [
|
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
setMissions(defaultMissions);
|
|
||||||
localStorage.setItem("missions", JSON.stringify(defaultMissions));
|
|
||||||
console.log(
|
|
||||||
"⚠️ Aucune mission trouvée, valeurs par défaut utilisées !"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Erreur lors du chargement des missions :", error);
|
console.error("❌ Erreur lors du chargement des missions :", error);
|
||||||
|
setMissions(defaultMissions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadMissions();
|
hydrateMissions();
|
||||||
}, []);
|
}, [missionItems, defaultMissions]);
|
||||||
|
|
||||||
// ✅ Mise à jour automatique de localStorage quand missions change
|
|
||||||
useEffect(() => {
|
|
||||||
if (missions.length > 0) {
|
|
||||||
localStorage.setItem("missions", JSON.stringify(missions));
|
|
||||||
}
|
|
||||||
}, [missions]);
|
|
||||||
|
|
||||||
// ✅ Gestion des changements depuis d'autres onglets/navigateurs
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorageChange = () => {
|
|
||||||
const updatedMissions =
|
|
||||||
JSON.parse(localStorage.getItem("missions")) || [];
|
|
||||||
setMissions(updatedMissions);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("storage", handleStorageChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("storage", handleStorageChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ✅ Gestion des accordéons
|
// ✅ Gestion des accordéons
|
||||||
const handleAccordionChange = (panel) => (event, isExpanded) => {
|
const handleAccordionChange = (panel) => (event, isExpanded) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { 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";
|
||||||
@@ -9,13 +9,14 @@ import PropTypes from "prop-types";
|
|||||||
const modalStyle = {
|
const modalStyle = {
|
||||||
padding: 4,
|
padding: 4,
|
||||||
borderRadius: "20px",
|
borderRadius: "20px",
|
||||||
background: "rgba(255, 255, 255, 0.9)",
|
background: "rgba(255, 255, 255, 0.95)",
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
margin: "auto",
|
margin: "auto",
|
||||||
mt: 4,
|
mt: 4,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
maxHeight: "80vh",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Style commun pour l'icône affichée en haut à droite dans le modal
|
// Style commun pour l'icône affichée en haut à droite dans le modal
|
||||||
@@ -54,19 +55,74 @@ const cardStyle = (image) => ({
|
|||||||
backgroundPosition: "center left",
|
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
|
// Composant Modal réutilisable
|
||||||
const ExpertiseModal = ({ modalId, image, title, text, openModal, handleClose }) => (
|
const ExpertiseModal = ({ modalId, image, title, text, openModal, handleClose }) => (
|
||||||
<Modal open={openModal === modalId} onClose={handleClose}>
|
<Modal open={openModal === modalId} onClose={handleClose}>
|
||||||
<Box sx={modalStyle}>
|
<Box sx={{ ...modalStyle, maxHeight: "80vh" }}>
|
||||||
<Box sx={{ ...iconStyle, backgroundImage: `url(${image})` }} />
|
<Box sx={{ ...iconStyle, backgroundImage: `url(${image})` }} />
|
||||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<ScrollableContent text={text} />
|
||||||
variant="body1"
|
|
||||||
sx={{ color: "#333", lineHeight: 1.6 }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
|
||||||
/>
|
|
||||||
<button onClick={handleClose} style={buttonStyle}>
|
<button onClick={handleClose} style={buttonStyle}>
|
||||||
Retour
|
Retour
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { updateHomePageACF, updateRankMathMeta, uploadImage } from "../../wordpress";
|
import { updateHomePageACF, updateRankMathMeta, uploadImage, getPageById } from "../../wordpress";
|
||||||
import { getToken } from "../../auth";
|
import { getToken } from "../../auth";
|
||||||
import api from "../../api";
|
import api from "../../api";
|
||||||
import {
|
import {
|
||||||
@@ -12,9 +12,17 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Stack,
|
Stack,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
IconButton,
|
||||||
|
Slider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import ReactQuill from "react-quill";
|
import ReactQuill from "react-quill";
|
||||||
import "react-quill/dist/quill.snow.css";
|
import "react-quill/dist/quill.snow.css";
|
||||||
|
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||||
|
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||||
|
|
||||||
const HOMEPAGE_ID = 13;
|
const HOMEPAGE_ID = 13;
|
||||||
const HOMEPAGE_URL = "https://preprod.octopusdesign.fr/api-octopus/server/";
|
const HOMEPAGE_URL = "https://preprod.octopusdesign.fr/api-octopus/server/";
|
||||||
@@ -51,9 +59,22 @@ const GestionPageAccueil = () => {
|
|||||||
const [seoOgDescription, setSeoOgDescription] = useState("");
|
const [seoOgDescription, setSeoOgDescription] = useState("");
|
||||||
const [seoOgImage, setSeoOgImage] = useState("");
|
const [seoOgImage, setSeoOgImage] = useState("");
|
||||||
const [seoRobots, setSeoRobots] = useState("");
|
const [seoRobots, setSeoRobots] = useState("");
|
||||||
|
const [seoFocusKeyword, setSeoFocusKeyword] = useState("");
|
||||||
const [seoLoading, setSeoLoading] = useState(true);
|
const [seoLoading, setSeoLoading] = useState(true);
|
||||||
const [seoError, setSeoError] = useState(null);
|
const [seoError, setSeoError] = useState(null);
|
||||||
|
|
||||||
|
const [imageDialog, setImageDialog] = useState({
|
||||||
|
open: false,
|
||||||
|
title: "",
|
||||||
|
setter: null,
|
||||||
|
getter: null,
|
||||||
|
mode: "insert",
|
||||||
|
targetIndex: null,
|
||||||
|
width: 100,
|
||||||
|
alt: "",
|
||||||
|
});
|
||||||
|
const [ogDialog, setOgDialog] = useState({ open: false, source: "" });
|
||||||
|
|
||||||
const glassCardSx = {
|
const glassCardSx = {
|
||||||
background: "rgba(7, 13, 28, 0.82)",
|
background: "rgba(7, 13, 28, 0.82)",
|
||||||
border: "1px solid rgba(132, 169, 255, 0.18)",
|
border: "1px solid rgba(132, 169, 255, 0.18)",
|
||||||
@@ -63,7 +84,6 @@ const GestionPageAccueil = () => {
|
|||||||
backdropFilter: "blur(18px)",
|
backdropFilter: "blur(18px)",
|
||||||
color: "#f4f7ff",
|
color: "#f4f7ff",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionTitleSx = {
|
const sectionTitleSx = {
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fontSize: { xs: "1.3rem", md: "1.6rem" },
|
fontSize: { xs: "1.3rem", md: "1.6rem" },
|
||||||
@@ -125,6 +145,284 @@ const GestionPageAccueil = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractExpertiseTitle = (value) => {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (typeof parsed === "string") return parsed;
|
||||||
|
if (parsed?.titre_expertise) return parsed.titre_expertise;
|
||||||
|
} catch (err) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value.titre_expertise) {
|
||||||
|
return value.titre_expertise;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImageDialog = (
|
||||||
|
title,
|
||||||
|
getter,
|
||||||
|
setter,
|
||||||
|
mode = "insert",
|
||||||
|
targetIndex = null,
|
||||||
|
initialAlt = ""
|
||||||
|
) => {
|
||||||
|
setImageDialog({
|
||||||
|
open: true,
|
||||||
|
title,
|
||||||
|
setter,
|
||||||
|
getter,
|
||||||
|
mode,
|
||||||
|
targetIndex,
|
||||||
|
width: 100,
|
||||||
|
alt: initialAlt || "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImageDialog = () =>
|
||||||
|
setImageDialog({
|
||||||
|
open: false,
|
||||||
|
title: "",
|
||||||
|
setter: null,
|
||||||
|
getter: null,
|
||||||
|
mode: "insert",
|
||||||
|
targetIndex: null,
|
||||||
|
width: 100,
|
||||||
|
alt: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildImageMarkup = (url, width, alt = "") =>
|
||||||
|
`<p><img src="${url}" alt="${alt?.replace(/"/g, """) || ""}" data-custom-width="${width}" width="${width}%" style="width:${width}%;max-width:100%;height:auto;" /></p>`;
|
||||||
|
|
||||||
|
const handleImageSelected = (url) => {
|
||||||
|
if (!url || !imageDialog.setter) return;
|
||||||
|
const targetWidth = imageDialog.width || 100;
|
||||||
|
|
||||||
|
const insertMarkup = () => {
|
||||||
|
const imageMarkup = buildImageMarkup(url, targetWidth, imageDialog.alt);
|
||||||
|
imageDialog.setter((prev) => `${prev || ""}${imageMarkup}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceImage = () => {
|
||||||
|
if (typeof window === "undefined" || !imageDialog.getter) {
|
||||||
|
insertMarkup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentHtml = imageDialog.getter() || "";
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const doc = parser.parseFromString(currentHtml, "text/html");
|
||||||
|
const images = doc.body.querySelectorAll("img");
|
||||||
|
const target = images[imageDialog.targetIndex ?? -1];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.setAttribute("src", url);
|
||||||
|
target.setAttribute("style", `width:${targetWidth}%;max-width:100%;height:auto;`);
|
||||||
|
target.setAttribute("data-custom-width", String(targetWidth));
|
||||||
|
target.setAttribute("width", `${targetWidth}%`);
|
||||||
|
target.setAttribute("alt", imageDialog.alt || target.getAttribute("alt") || "");
|
||||||
|
imageDialog.setter(doc.body.innerHTML);
|
||||||
|
} else {
|
||||||
|
insertMarkup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageDialog.mode === "replace") {
|
||||||
|
replaceImage();
|
||||||
|
} else {
|
||||||
|
insertMarkup();
|
||||||
|
}
|
||||||
|
closeImageDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImagesFromHtml = (html) => {
|
||||||
|
if (typeof window === "undefined" || !html) return [];
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
return Array.from(doc.body.querySelectorAll("img")).map((img) => {
|
||||||
|
const dataWidth = img.getAttribute("data-custom-width");
|
||||||
|
if (dataWidth) {
|
||||||
|
return {
|
||||||
|
src: img.getAttribute("src"),
|
||||||
|
width: Number(dataWidth) || 100,
|
||||||
|
alt: img.getAttribute("alt") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const attrWidth = img.getAttribute("width");
|
||||||
|
if (attrWidth && attrWidth.includes("%")) {
|
||||||
|
return {
|
||||||
|
src: img.getAttribute("src"),
|
||||||
|
width: Number(attrWidth.replace("%", "")) || 100,
|
||||||
|
alt: img.getAttribute("alt") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const styleAttr = img.getAttribute("style") || "";
|
||||||
|
const widthMatch = styleAttr.match(/width:\s*(\d+(?:\.\d+)?)%/i);
|
||||||
|
return {
|
||||||
|
src: img.getAttribute("src"),
|
||||||
|
width: widthMatch ? Number(widthMatch[1]) : 100,
|
||||||
|
alt: img.getAttribute("alt") || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImageAt = (getter, setter, index) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const currentHtml = getter() || "";
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const doc = parser.parseFromString(currentHtml, "text/html");
|
||||||
|
const images = doc.body.querySelectorAll("img");
|
||||||
|
const target = images[index];
|
||||||
|
if (target) {
|
||||||
|
target.remove();
|
||||||
|
setter(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateImageWidth = (getter, setter, index, width) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const currentHtml = getter() || "";
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const doc = parser.parseFromString(currentHtml, "text/html");
|
||||||
|
const images = doc.body.querySelectorAll("img");
|
||||||
|
const target = images[index];
|
||||||
|
if (target) {
|
||||||
|
target.setAttribute("style", `width:${width}%;max-width:100%;height:auto;`);
|
||||||
|
target.setAttribute("data-custom-width", String(width));
|
||||||
|
target.setAttribute("width", `${width}%`);
|
||||||
|
setter(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateImageAlt = (getter, setter, index, alt) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const currentHtml = getter() || "";
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const doc = parser.parseFromString(currentHtml, "text/html");
|
||||||
|
const images = doc.body.querySelectorAll("img");
|
||||||
|
const target = images[index];
|
||||||
|
if (target) {
|
||||||
|
target.setAttribute("alt", alt);
|
||||||
|
setter(doc.body.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderImageManager = (label, getter, setter, key) => {
|
||||||
|
const images = getImagesFromHtml(getter());
|
||||||
|
if (!images.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography sx={{ fontWeight: "bold", mb: 1 }}>
|
||||||
|
Images insérées – {label}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{images.map((img, index) => (
|
||||||
|
<Paper
|
||||||
|
key={`${key}-${index}`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
borderColor: "rgba(255,255,255,0.15)",
|
||||||
|
backgroundColor: "rgba(2,6,18,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={img.src}
|
||||||
|
alt={`${label}-${index}`}
|
||||||
|
sx={{
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems="center"
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 220 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: "rgba(255,255,255,0.7)", mb: 0.5, display: "block" }}
|
||||||
|
>
|
||||||
|
Largeur : {img.width}%
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={img.width}
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onChange={(_, value) =>
|
||||||
|
updateImageWidth(
|
||||||
|
getter,
|
||||||
|
setter,
|
||||||
|
index,
|
||||||
|
Array.isArray(value) ? value[0] : value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
color: "#45d6ff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
sx={primaryButtonSx}
|
||||||
|
onClick={() =>
|
||||||
|
openImageDialog(
|
||||||
|
`Remplacer – ${label}`,
|
||||||
|
getter,
|
||||||
|
setter,
|
||||||
|
"replace",
|
||||||
|
index,
|
||||||
|
img.alt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remplacer
|
||||||
|
</Button>
|
||||||
|
<TextField
|
||||||
|
label="Texte alternatif"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={img.alt}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateImageAlt(getter, setter, index, e.target.value)
|
||||||
|
}
|
||||||
|
InputLabelProps={{ sx: labelSx }}
|
||||||
|
sx={textFieldSx}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
sx={ghostButtonSx}
|
||||||
|
onClick={() => removeImageAt(getter, setter, index)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const loadSeoMeta = useCallback(async () => {
|
const loadSeoMeta = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSeoLoading(true);
|
setSeoLoading(true);
|
||||||
@@ -220,7 +518,7 @@ const GestionPageAccueil = () => {
|
|||||||
|
|
||||||
// ✅ Expertises
|
// ✅ Expertises
|
||||||
|
|
||||||
setexpertiseTitle(data.acf?.titre_expertise || "");
|
setexpertiseTitle(extractExpertiseTitle(data.acf?.titre_expertise));
|
||||||
setexpertise1Title(data.acf?.gdc_expert?.titre_expertise_1 || "");
|
setexpertise1Title(data.acf?.gdc_expert?.titre_expertise_1 || "");
|
||||||
setexpertise1Text(data.acf?.gdc_expert?.text_expertise_1 || "");
|
setexpertise1Text(data.acf?.gdc_expert?.text_expertise_1 || "");
|
||||||
setexpertise2Title(data.acf?.gdc_expert?.titre_expertise_2 || "");
|
setexpertise2Title(data.acf?.gdc_expert?.titre_expertise_2 || "");
|
||||||
@@ -240,12 +538,33 @@ const GestionPageAccueil = () => {
|
|||||||
loadSeoMeta();
|
loadSeoMeta();
|
||||||
}, [loadSeoMeta]);
|
}, [loadSeoMeta]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFocusKeyword = async () => {
|
||||||
|
try {
|
||||||
|
const pageData = await getPageById(HOMEPAGE_ID);
|
||||||
|
const focus =
|
||||||
|
pageData?._rank_math_seo_meta?.focus_keyword ||
|
||||||
|
pageData?._rank_math_seo_meta?.primary_keyword ||
|
||||||
|
"";
|
||||||
|
setSeoFocusKeyword(focus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Erreur chargement mot-clé principal :", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchFocusKeyword();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ✅ Enregistrement ACF + Rank Math
|
// ✅ Enregistrement ACF + Rank Math
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMessage("");
|
setSuccessMessage("");
|
||||||
|
|
||||||
|
const formattedExpertiseTitle =
|
||||||
|
typeof expertiseTitle === "string"
|
||||||
|
? { titre_expertise: expertiseTitle }
|
||||||
|
: expertiseTitle || { titre_expertise: "" };
|
||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
hero_title: heroTitle,
|
hero_title: heroTitle,
|
||||||
hero_text: heroText,
|
hero_text: heroText,
|
||||||
@@ -259,7 +578,7 @@ const GestionPageAccueil = () => {
|
|||||||
construct_text: constructText,
|
construct_text: constructText,
|
||||||
},
|
},
|
||||||
gdc_expert: {
|
gdc_expert: {
|
||||||
titre_expertise: expertiseTitle,
|
titre_expertise: formattedExpertiseTitle,
|
||||||
titre_expertise_1: expertise1Title,
|
titre_expertise_1: expertise1Title,
|
||||||
text_expertise_1: expertise1Text,
|
text_expertise_1: expertise1Text,
|
||||||
titre_expertise_2: expertise2Title,
|
titre_expertise_2: expertise2Title,
|
||||||
@@ -282,6 +601,7 @@ const GestionPageAccueil = () => {
|
|||||||
rank_math_twitter_description: seoOgDescription || seoDescription,
|
rank_math_twitter_description: seoOgDescription || seoDescription,
|
||||||
rank_math_twitter_image: seoOgImage,
|
rank_math_twitter_image: seoOgImage,
|
||||||
rank_math_robots: seoRobots,
|
rank_math_robots: seoRobots,
|
||||||
|
rank_math_focus_keyword: seoFocusKeyword,
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem("missions", JSON.stringify(missions));
|
localStorage.setItem("missions", JSON.stringify(missions));
|
||||||
@@ -543,6 +863,16 @@ const GestionPageAccueil = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Mot-clé principal"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={seoFocusKeyword}
|
||||||
|
onChange={(e) => setSeoFocusKeyword(e.target.value)}
|
||||||
|
helperText="Mot-clé Rank Math principal (focus keyword)."
|
||||||
|
InputLabelProps={{ sx: labelSx }}
|
||||||
|
sx={textFieldSx}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Meta Title"
|
label="Meta Title"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -593,16 +923,48 @@ const GestionPageAccueil = () => {
|
|||||||
InputLabelProps={{ sx: labelSx }}
|
InputLabelProps={{ sx: labelSx }}
|
||||||
sx={textFieldSx}
|
sx={textFieldSx}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<Stack spacing={1}>
|
||||||
label="Image OpenGraph (URL)"
|
<TextField
|
||||||
fullWidth
|
label="Image OpenGraph (URL)"
|
||||||
variant="outlined"
|
fullWidth
|
||||||
value={seoOgImage}
|
variant="outlined"
|
||||||
onChange={(e) => setSeoOgImage(e.target.value)}
|
value={seoOgImage}
|
||||||
placeholder="https://..."
|
onChange={(e) => setSeoOgImage(e.target.value)}
|
||||||
InputLabelProps={{ sx: labelSx }}
|
placeholder="https://..."
|
||||||
sx={textFieldSx}
|
InputLabelProps={{ sx: labelSx }}
|
||||||
/>
|
sx={textFieldSx}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...ghostButtonSx, flex: 1 }}
|
||||||
|
onClick={() => setOgDialog({ open: true, source: "upload" })}
|
||||||
|
>
|
||||||
|
Importer depuis l'ordinateur
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...ghostButtonSx, flex: 1 }}
|
||||||
|
onClick={() => setOgDialog({ open: true, source: "library" })}
|
||||||
|
>
|
||||||
|
Choisir dans Cloudinary
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{seoOgImage && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={seoOgImage}
|
||||||
|
alt="Aperçu OG"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: 180,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robots"
|
label="Robots"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -727,6 +1089,28 @@ const GestionPageAccueil = () => {
|
|||||||
onChange={setConstructText}
|
onChange={setConstructText}
|
||||||
style={quillStyle}
|
style={quillStyle}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...ghostButtonSx, mt: 1 }}
|
||||||
|
onClick={() =>
|
||||||
|
openImageDialog(
|
||||||
|
"Texte Construct",
|
||||||
|
() => constructText,
|
||||||
|
setConstructText,
|
||||||
|
"insert",
|
||||||
|
null,
|
||||||
|
"Image texte construct"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
➕ Insérer une image Cloudinary
|
||||||
|
</Button>
|
||||||
|
{renderImageManager(
|
||||||
|
"Texte Construct",
|
||||||
|
() => constructText,
|
||||||
|
setConstructText,
|
||||||
|
"construct-text"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
@@ -737,6 +1121,28 @@ const GestionPageAccueil = () => {
|
|||||||
onChange={setConstructNote}
|
onChange={setConstructNote}
|
||||||
style={quillStyle}
|
style={quillStyle}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...ghostButtonSx, mt: 1 }}
|
||||||
|
onClick={() =>
|
||||||
|
openImageDialog(
|
||||||
|
"Notes Construct",
|
||||||
|
() => constructNote,
|
||||||
|
setConstructNote,
|
||||||
|
"insert",
|
||||||
|
null,
|
||||||
|
"Image notes construct"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
➕ Insérer une image Cloudinary
|
||||||
|
</Button>
|
||||||
|
{renderImageManager(
|
||||||
|
"Notes Construct",
|
||||||
|
() => constructNote,
|
||||||
|
setConstructNote,
|
||||||
|
"construct-note"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -779,6 +1185,18 @@ const GestionPageAccueil = () => {
|
|||||||
: index === 2
|
: index === 2
|
||||||
? setexpertise2Text
|
? setexpertise2Text
|
||||||
: setexpertise3Text;
|
: setexpertise3Text;
|
||||||
|
const cloudKey =
|
||||||
|
index === 1
|
||||||
|
? "expertise1"
|
||||||
|
: index === 2
|
||||||
|
? "expertise2"
|
||||||
|
: "expertise3";
|
||||||
|
const getTextValue = () =>
|
||||||
|
index === 1
|
||||||
|
? expertise1Text
|
||||||
|
: index === 2
|
||||||
|
? expertise2Text
|
||||||
|
: expertise3Text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -808,6 +1226,28 @@ const GestionPageAccueil = () => {
|
|||||||
onChange={setText}
|
onChange={setText}
|
||||||
style={quillStyle}
|
style={quillStyle}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ...ghostButtonSx, mt: 1 }}
|
||||||
|
onClick={() =>
|
||||||
|
openImageDialog(
|
||||||
|
`Expertise ${index}`,
|
||||||
|
getTextValue,
|
||||||
|
setText,
|
||||||
|
"insert",
|
||||||
|
null,
|
||||||
|
`Image expertise ${index}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
➕ Insérer une image Cloudinary
|
||||||
|
</Button>
|
||||||
|
{renderImageManager(
|
||||||
|
`Expertise ${index}`,
|
||||||
|
getTextValue,
|
||||||
|
setText,
|
||||||
|
`expertise-${index}`
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -831,6 +1271,113 @@ const GestionPageAccueil = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={imageDialog.open}
|
||||||
|
onClose={closeImageDialog}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
pr: 6,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#0b1b3a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageDialog.title || "Ajouter une image"}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Fermer"
|
||||||
|
onClick={closeImageDialog}
|
||||||
|
sx={{ position: "absolute", right: 16, top: 16 }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Largeur (%)"
|
||||||
|
type="number"
|
||||||
|
value={imageDialog.width}
|
||||||
|
onChange={(e) =>
|
||||||
|
setImageDialog((prev) => ({
|
||||||
|
...prev,
|
||||||
|
width: Math.min(100, Math.max(10, Number(e.target.value) || 100)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
InputLabelProps={{ sx: { color: "#0b1b3a" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Texte alternatif (description)"
|
||||||
|
fullWidth
|
||||||
|
value={imageDialog.alt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setImageDialog((prev) => ({
|
||||||
|
...prev,
|
||||||
|
alt: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
helperText="Décrivez brièvement l'image pour l'accessibilité."
|
||||||
|
InputLabelProps={{ sx: { color: "#0b1b3a" } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: "bold", mb: 1 }}>
|
||||||
|
Importer depuis votre ordinateur :
|
||||||
|
</Typography>
|
||||||
|
<ImageUploaderCloudinary onUploadSuccess={handleImageSelected} />
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: "bold", mb: 1 }}>
|
||||||
|
Ou sélectionner une image existante :
|
||||||
|
</Typography>
|
||||||
|
<CloudinaryGallerySelector onSelect={handleImageSelected} />
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={ogDialog.open}
|
||||||
|
onClose={() => setOgDialog({ open: false, source: "" })}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
pr: 6,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#0b1b3a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Image OpenGraph
|
||||||
|
<IconButton
|
||||||
|
aria-label="Fermer"
|
||||||
|
onClick={() => setOgDialog({ open: false, source: "" })}
|
||||||
|
sx={{ position: "absolute", right: 16, top: 16 }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{ogDialog.source === "upload" ? (
|
||||||
|
<ImageUploaderCloudinary
|
||||||
|
onUploadSuccess={(url) => {
|
||||||
|
setSeoOgImage(url);
|
||||||
|
setOgDialog({ open: false, source: "" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloudinaryGallerySelector
|
||||||
|
onSelect={(url) => {
|
||||||
|
setSeoOgImage(url);
|
||||||
|
setOgDialog({ open: false, source: "" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const Home = () => {
|
|||||||
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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user