'use client'; import { useState, useEffect, useRef } from 'react'; import { getAllBlogPosts, createBlogPost, updateBlogPost, deleteBlogPost, type CreateBlogPostRequest, type UpdateBlogPostRequest, } from '@/lib/api/admin'; import { upload } from '@/lib/api/client'; import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog'; import { PageHeader } from '@/components/ui/PageHeader'; import { RichTextEditor } from '@/components/blog/RichTextEditor'; import { Plus, Pencil, Trash2, Eye, EyeOff, Star, StarOff, ExternalLink, ImageIcon, X, Loader2, Search, Clock, ChevronDown, ChevronUp, ArrowLeft, } from 'lucide-react'; import { Link } from '@/i18n/navigation'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; function normalizeImageUrl(url: string | null | undefined): string { if (!url) return ''; if (url.startsWith('http')) return url; return `${API_BASE_URL}${url}`; } function normalizeContentUrls(html: string): string { return html.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`); } const CATEGORIES: { value: BlogPostCategory; label: string }[] = [ { value: 'industry', label: 'Industrie' }, { value: 'technology', label: 'Technologie' }, { value: 'guides', label: 'Guides' }, { value: 'news', label: 'Actualités' }, ]; const STATUS_LABELS: Record = { draft: 'Brouillon', scheduled: 'Planifié', published: 'Publié', archived: 'Archivé', }; const STATUS_COLORS: Record = { draft: 'bg-yellow-100 text-yellow-800', scheduled: 'bg-blue-100 text-blue-800', published: 'bg-green-100 text-green-800', archived: 'bg-gray-100 text-gray-600', }; interface FormData extends CreateBlogPostRequest { metaTitle: string; metaDescription: string; primaryKeyword: string; secondaryKeywordsInput: string; } const EMPTY_FORM: FormData = { title: '', slug: '', excerpt: '', content: '', coverImageUrl: '', category: 'industry', tags: [], authorName: '', metaTitle: '', metaDescription: '', primaryKeyword: '', secondaryKeywordsInput: '', }; function generateSlug(title: string): string { return title .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-'); } function toLocalDatetimeValue(dateStr?: string): string { if (!dateStr) return ''; const d = new Date(dateStr); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } // SEO preview snippet function SeoPreview({ metaTitle, metaDescription, slug, }: { metaTitle: string; metaDescription: string; slug: string; }) { const displayTitle = metaTitle || "Titre de l'article"; const displayDesc = metaDescription || "Description de l'article..."; const displayUrl = `xpeditis.com/blog/${slug || 'votre-slug'}`; return (

Aperçu Google

{displayTitle}

{displayUrl}

{displayDesc}

); } export default function AdminBlogPage() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPost, setSelectedPost] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [saving, setSaving] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); const [tagsInput, setTagsInput] = useState(''); const [editStatus, setEditStatus] = useState('draft'); const [formData, setFormData] = useState(EMPTY_FORM); const [scheduledAt, setScheduledAt] = useState(''); const [seoOpen, setSeoOpen] = useState(false); const coverInputRef = useRef(null); useEffect(() => { fetchPosts(); }, []); const fetchPosts = async () => { try { setLoading(true); const res = await getAllBlogPosts(); setPosts(res.posts); setError(null); } catch (err: any) { setError(err.message || 'Erreur lors du chargement des articles'); } finally { setLoading(false); } }; const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => { const tags = tagsInput ? tagsInput .split(',') .map(t => t.trim()) .filter(Boolean) : []; const secondaryKeywords = formData.secondaryKeywordsInput ? formData.secondaryKeywordsInput .split(',') .map(t => t.trim()) .filter(Boolean) : []; return { title: formData.title, slug: formData.slug, excerpt: formData.excerpt, content: formData.content, // Send explicit null (not undefined) when the cover was removed, so the // backend clears the column instead of leaving the previous value. coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null, category: formData.category, tags, authorName: formData.authorName, metaTitle: formData.metaTitle || undefined, metaDescription: formData.metaDescription || undefined, primaryKeyword: formData.primaryKeyword || undefined, secondaryKeywords, scheduledAt: scheduledAt || undefined, ...(withStatus ? { status: withStatus } : {}), }; }; const handleCreate = async (e: React.FormEvent, publishNow?: boolean) => { e.preventDefault(); setSaving(true); try { const payload = buildPayload(publishNow ? 'published' : undefined); if (publishNow) payload.scheduledAt = undefined; await createBlogPost(payload); await fetchPosts(); closeModal(); } catch (err: any) { alert(err.message || 'Erreur lors de la création'); } finally { setSaving(false); } }; const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedPost) return; setSaving(true); try { const data: UpdateBlogPostRequest = { ...buildPayload(editStatus), }; await updateBlogPost(selectedPost.id, data); await fetchPosts(); closeModal(); } catch (err: any) { alert(err.message || 'Erreur lors de la mise à jour'); } finally { setSaving(false); } }; const handleDelete = async () => { if (!selectedPost) return; try { await deleteBlogPost(selectedPost.id); await fetchPosts(); setShowDeleteConfirm(false); setSelectedPost(null); } catch (err: any) { alert(err.message || 'Erreur lors de la suppression'); } }; const handleToggleStatus = async (post: BlogPost) => { const nextStatus: BlogPostStatus = post.status === 'published' ? 'draft' : 'published'; try { await updateBlogPost(post.id, { status: nextStatus }); await fetchPosts(); } catch (err: any) { alert(err.message || 'Erreur lors du changement de statut'); } }; const handleToggleFeatured = async (post: BlogPost) => { try { await updateBlogPost(post.id, { isFeatured: !post.isFeatured }); await fetchPosts(); } catch (err: any) { alert(err.message || 'Erreur lors du changement'); } }; const handleCoverUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { alert('Image trop volumineuse (max 5 Mo)'); return; } setUploadingCover(true); try { const fd = new FormData(); fd.append('image', file); const result = await upload<{ url: string; filename: string }>( '/api/v1/admin/blog/images', fd ); const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`; setFormData(prev => ({ ...prev, coverImageUrl: coverUrl })); } catch (err: any) { alert(err.message || "Erreur lors de l'upload"); } finally { setUploadingCover(false); if (coverInputRef.current) coverInputRef.current.value = ''; } }; const openCreate = () => { setFormData(EMPTY_FORM); setTagsInput(''); setScheduledAt(''); setEditStatus('draft'); setSelectedPost(null); setSeoOpen(false); setShowCreateModal(true); }; const openEdit = (post: BlogPost) => { setSelectedPost(post); setFormData({ title: post.title, slug: post.slug, excerpt: post.excerpt, content: normalizeContentUrls(post.content), coverImageUrl: normalizeImageUrl(post.coverImageUrl), category: post.category, tags: post.tags, authorName: post.authorName, metaTitle: post.metaTitle ?? '', metaDescription: post.metaDescription ?? '', primaryKeyword: post.primaryKeyword ?? '', secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '), }); setTagsInput(post.tags.join(', ')); setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : ''); setEditStatus(post.status); setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword)); setShowEditModal(true); }; // Pure UI reset — does not touch browser history. Used by the popstate // handler (the browser already popped our entry at that point). const resetEditorState = () => { setShowCreateModal(false); setShowEditModal(false); setSelectedPost(null); setFormData(EMPTY_FORM); setTagsInput(''); setScheduledAt(''); setSeoOpen(false); }; // Explicit close (X / Retour / after save): reset UI and pop the history // entry we pushed when opening, so the browser Back button stays on /admin/blog. const closeModal = () => { resetEditorState(); if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) { window.history.back(); } }; // Keep the browser Back button inside the blog editor: pushing a history entry // when the editor opens means Back closes the editor instead of navigating away // (e.g. to the System Logs tab). useEffect(() => { const editorOpen = showCreateModal || showEditModal; if (!editorOpen) return; window.history.pushState({ xpBlogEditor: true }, ''); const handlePop = () => resetEditorState(); window.addEventListener('popstate', handlePop); return () => window.removeEventListener('popstate', handlePop); // eslint-disable-next-line react-hooks/exhaustive-deps }, [showCreateModal, showEditModal]); const handleTitleChange = (title: string) => { setFormData(prev => ({ ...prev, title, slug: prev.slug === generateSlug(prev.title) || prev.slug === '' ? generateSlug(title) : prev.slug, metaTitle: prev.metaTitle === prev.title || prev.metaTitle === '' ? title : prev.metaTitle, })); }; const metaTitleLen = formData.metaTitle.length; const metaDescLen = formData.metaDescription.length; const metaTitleColor = metaTitleLen === 0 ? 'text-gray-400' : metaTitleLen <= 60 ? 'text-green-600' : 'text-red-500'; const metaDescColor = metaDescLen === 0 ? 'text-gray-400' : metaDescLen <= 160 ? 'text-green-600' : 'text-red-500'; const isOpen = showCreateModal || showEditModal; const isScheduleMode = !!scheduledAt; return (
Nouvel article } /> {error && (
{error}
)} {loading ? (
Chargement des articles...
) : (
{posts.length === 0 ? ( ) : ( posts.map(post => ( )) )}
Article Catégorie Statut Auteur Date Actions
Aucun article. Créez votre premier article !
{post.coverImageUrl && ( )}
{post.isFeatured && ( )} {post.title}
{post.slug}
{CATEGORIES.find(c => c.value === post.category)?.label ?? post.category}
{STATUS_LABELS[post.status]} {post.status === 'scheduled' && post.publishedAt && ( {new Date(post.publishedAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', })} )}
{post.authorName} {post.publishedAt ? new Date(post.publishedAt).toLocaleDateString('fr-FR') : new Date(post.createdAt).toLocaleDateString('fr-FR')}
{(post.status === 'published' || post.status === 'scheduled') && ( )}
)} {/* Create / Edit Modal — Full-screen overlay */} {isOpen && (
{/* Header */}

{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}

{showEditModal && ( )}
{/* Body */}
{/* Main editor — 2/3 */}
{/* Title */}
handleTitleChange(e.target.value)} className="w-full text-3xl font-bold text-gray-900 border-0 border-b-2 border-gray-200 focus:border-blue-500 focus:outline-none pb-2 placeholder-gray-300 bg-transparent" placeholder="Titre de l'article..." />
{/* Excerpt */}