Files
Octopus-React-Wp/frontend/src/components/Pages/GestionArticles.jsx
T
2025-11-06 11:28:07 +01:00

567 lines
17 KiB
React

// ✅ Importations nécessaires
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Typography,
Button,
Grid,
Card,
CardContent,
CardMedia,
TextField,
Stack,
Chip,
CircularProgress,
Tooltip,
} from "@mui/material";
import { Add, ArrowBack, Edit, Delete } from "@mui/icons-material";
import api from "../../api";
import { getToken } from "../../auth";
import { deletePost } from "../../wordpress";
import "../../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
danger: {
background: "linear-gradient(135deg, #f05b6b, #ff8a80)",
color: "#3a0a0f",
border: "1px solid rgba(240,91,107,0.35)",
boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)",
"&:hover": {
background: "linear-gradient(135deg, #e34659, #ff7575)",
boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
const GestionArticles = () => {
const [posts, setPosts] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
// ✅ Vérifier si l'utilisateur est connecté
useEffect(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
// ✅ Récupérer les articles et leurs images associées
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true);
const response = await api.get(
"wp/v2/posts?_fields=id,title,excerpt,featured_media,date,status"
);
const postsData = response.data;
const postsWithImages = await Promise.all(
postsData.map(async (post) => {
if (post.featured_media) {
try {
const mediaResponse = await api.get(`wp/v2/media/${post.featured_media}`);
return { ...post, image: mediaResponse.data.source_url, imageId: post.featured_media };
} catch (error) {
console.error(`❌ Erreur chargement image article ${post.id}:`, error);
return { ...post, image: null, imageId: null };
}
}
return { ...post, image: null, imageId: null };
})
);
setPosts(postsWithImages);
} catch (error) {
console.error("❌ Erreur chargement des articles :", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
// ✅ Supprimer un article et son image associée
const stripHtml = (value = "") =>
value.replace(/(<([^>]+)>)/gi, "").replace(/&nbsp;/gi, " ").trim();
const previewExcerpt = (value = "", limit = 160) => {
const clean = stripHtml(value);
return clean.length > limit ? `${clean.slice(0, limit)}…` : clean;
};
const formatDate = (value) => {
if (!value) {
return "Date inconnue";
}
try {
return new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return "Date inconnue";
}
};
const handleDeletePost = async (postId, imageId) => {
const confirmed = window.confirm(
"Supprimer cet article et son visuel associé ?"
);
if (!confirmed) {
return;
}
try {
await deletePost(postId, imageId);
setPosts((prev) => prev.filter((post) => post.id !== postId));
alert("✅ Article supprimé avec succès !");
} catch (error) {
console.error("❌ Erreur suppression article :", error);
alert("⚠ Erreur : impossible de supprimer l'article.");
}
};
const handleCreate = () => navigate("/admin/create-post");
const handleBack = () => navigate("/admin/dashboard");
const handleEdit = (postId) => navigate(`/admin/edit-post/${postId}`);
const filteredPosts = useMemo(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) {
return posts;
}
return posts.filter((post) => {
const title = post.title?.rendered?.toLowerCase?.() || "";
const excerpt = stripHtml(post.excerpt?.rendered || "").toLowerCase();
return title.includes(term) || excerpt.includes(term);
});
}, [posts, searchTerm]);
const stats = useMemo(() => {
const total = posts.length;
const withImage = posts.reduce(
(acc, post) => (post.image ? acc + 1 : acc),
0
);
const latest = posts.reduce((current, candidate) => {
if (!candidate?.date) {
return current;
}
if (!current) {
return candidate;
}
return new Date(candidate.date) > new Date(current.date)
? candidate
: current;
}, null);
return {
total,
withImage,
withoutImage: total - withImage,
latestLabel: latest?.title?.rendered || "Aucun article",
latestDate: latest?.date ? formatDate(latest.date) : "—",
};
}, [posts]);
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="contained"
onClick={handleBack}
sx={{ ...composeButtonSx("ghost") }}
>
Retour
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Contenu éditorial
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Gestion des articles
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Consultez les publications WordPress, ajustez vos contenus et
maintenez un flux éditorial cohérent pour Octopus.
</Typography>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary") }}
>
Nouvel article
</Button>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Total articles
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.total}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{filteredPosts.length} résultat(s) après filtre
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Articles illustrés
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.withImage}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.withoutImage} sans visuel associé
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Dernière publication
</Typography>
<Typography
variant="subtitle1"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{stats.latestLabel}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.latestDate}
</Typography>
</Card>
</Grid>
</Grid>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
mb: 4,
boxShadow: "0 24px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
Rechercher un article
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Filtrez par titre ou résumé pour retrouver un contenu en quelques
secondes.
</Typography>
</Box>
<TextField
label="Rechercher un article..."
variant="outlined"
fullWidth
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
sx={{
maxWidth: 380,
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.92)",
},
}}
/>
</Stack>
</Card>
{loading ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<CircularProgress color="inherit" />
</Box>
) : filteredPosts.length === 0 ? (
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: 4,
textAlign: "center",
color: "#0b1a3d",
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
Aucun article trouvé
</Typography>
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Ajustez votre recherche ou créez un nouvel article pour alimenter le blog.
</Typography>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary"), mt: 3 }}
>
Créer un article
</Button>
</Card>
) : (
<Grid container spacing={2.5}>
{filteredPosts.map((post) => (
<Grid item xs={12} md={6} key={post.id}>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
boxShadow: "0 32px 40px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardMedia
component="div"
image={
post.image ||
"https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&w=1200&q=80"
}
sx={{
height: 200,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<CardContent
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
label={`ID ${post.id}`}
sx={{
bgcolor: "rgba(49,83,151,0.12)",
color: "#315397",
fontWeight: 600,
}}
/>
<Tooltip title={stats.latestLabel === post.title.rendered ? "Dernière publication" : ""}>
<Chip
size="small"
label={formatDate(post.date)}
sx={{
bgcolor: "rgba(255,255,255,0.72)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Tooltip>
</Stack>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{post.title.rendered}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
>
{previewExcerpt(post.excerpt?.rendered)}
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: "auto" }}
>
<Button
startIcon={<Edit />}
variant="contained"
onClick={() => handleEdit(post.id)}
sx={{ ...composeButtonSx("outline", "small") }}
>
Modifier
</Button>
<Button
startIcon={<Delete />}
variant="contained"
onClick={() => handleDeletePost(post.id, post.imageId)}
sx={{ ...composeButtonSx("danger", "small") }}
>
Supprimer
</Button>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
</Box>
);
};
export default GestionArticles;