943 lines
37 KiB
TypeScript
943 lines
37 KiB
TypeScript
'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,
|
|
} 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<BlogPostStatus, string> = {
|
|
draft: 'Brouillon',
|
|
scheduled: 'Planifié',
|
|
published: 'Publié',
|
|
archived: 'Archivé',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<BlogPostStatus, string> = {
|
|
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 (
|
|
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 text-sm">
|
|
<p className="text-xs text-gray-400 mb-2 font-medium uppercase tracking-wide">
|
|
Aperçu Google
|
|
</p>
|
|
<p className="text-blue-700 text-base font-medium truncate leading-tight">{displayTitle}</p>
|
|
<p className="text-green-700 text-xs mt-0.5 truncate">{displayUrl}</p>
|
|
<p className="text-gray-600 text-xs mt-1 line-clamp-2 leading-relaxed">{displayDesc}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminBlogPage() {
|
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedPost, setSelectedPost] = useState<BlogPost | null>(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<BlogPostStatus>('draft');
|
|
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
|
const [scheduledAt, setScheduledAt] = useState('');
|
|
const [seoOpen, setSeoOpen] = useState(false);
|
|
const coverInputRef = useRef<HTMLInputElement>(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,
|
|
coverImageUrl: formData.coverImageUrl || undefined,
|
|
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<HTMLInputElement>) => {
|
|
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);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowCreateModal(false);
|
|
setShowEditModal(false);
|
|
setSelectedPost(null);
|
|
setFormData(EMPTY_FORM);
|
|
setTagsInput('');
|
|
setScheduledAt('');
|
|
setSeoOpen(false);
|
|
};
|
|
|
|
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 (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<PageHeader
|
|
title="Gestion du Blog"
|
|
description="Créez, modifiez et publiez les articles du blog"
|
|
actions={
|
|
<button
|
|
onClick={openCreate}
|
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Nouvel article</span>
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-6">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Article
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Catégorie
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Statut
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Auteur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Date
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{posts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
|
Aucun article. Créez votre premier article !
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
posts.map(post => (
|
|
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-start space-x-3">
|
|
{post.coverImageUrl && (
|
|
<img
|
|
src={post.coverImageUrl}
|
|
alt=""
|
|
className="w-12 h-12 object-cover rounded flex-shrink-0"
|
|
/>
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
{post.isFeatured && (
|
|
<Star className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium text-gray-900 line-clamp-1">
|
|
{post.title}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-400 font-mono mt-0.5">{post.slug}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{CATEGORIES.find(c => c.value === post.category)?.label ?? post.category}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col gap-1">
|
|
<span
|
|
className={`px-2 py-1 rounded-full text-xs font-medium w-fit ${STATUS_COLORS[post.status]}`}
|
|
>
|
|
{STATUS_LABELS[post.status]}
|
|
</span>
|
|
{post.status === 'scheduled' && post.publishedAt && (
|
|
<span className="text-xs text-gray-400 flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{new Date(post.publishedAt).toLocaleString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">{post.authorName}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-500">
|
|
{post.publishedAt
|
|
? new Date(post.publishedAt).toLocaleDateString('fr-FR')
|
|
: new Date(post.createdAt).toLocaleDateString('fr-FR')}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center justify-end space-x-1">
|
|
{(post.status === 'published' || post.status === 'scheduled') && (
|
|
<Link href={`/blog/${post.slug}`} target="_blank">
|
|
<button
|
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
|
title="Voir"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</button>
|
|
</Link>
|
|
)}
|
|
<button
|
|
onClick={() => handleToggleFeatured(post)}
|
|
className="p-1.5 text-gray-400 hover:text-yellow-500 transition-colors"
|
|
title={post.isFeatured ? 'Retirer de la une' : 'Mettre à la une'}
|
|
>
|
|
{post.isFeatured ? (
|
|
<StarOff className="w-4 h-4" />
|
|
) : (
|
|
<Star className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggleStatus(post)}
|
|
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors"
|
|
title={post.status === 'published' ? 'Dépublier' : 'Publier'}
|
|
>
|
|
{post.status === 'published' ? (
|
|
<EyeOff className="w-4 h-4" />
|
|
) : (
|
|
<Eye className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => openEdit(post)}
|
|
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
|
title="Modifier"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSelectedPost(post);
|
|
setShowDeleteConfirm(true);
|
|
}}
|
|
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create / Edit Modal — Full-screen overlay */}
|
|
{isOpen && (
|
|
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
|
</h2>
|
|
<div className="flex items-center gap-3">
|
|
{showEditModal && (
|
|
<select
|
|
value={editStatus}
|
|
onChange={e => setEditStatus(e.target.value as BlogPostStatus)}
|
|
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="draft">Brouillon</option>
|
|
<option value="scheduled">Planifié</option>
|
|
<option value="published">Publié</option>
|
|
<option value="archived">Archivé</option>
|
|
</select>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={closeModal}
|
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<form
|
|
onSubmit={showCreateModal ? handleCreate : handleUpdate}
|
|
className="max-w-6xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8"
|
|
>
|
|
{/* Main editor — 2/3 */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Title */}
|
|
<div>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.title}
|
|
onChange={e => 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..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Excerpt */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Extrait *</label>
|
|
<textarea
|
|
required
|
|
rows={2}
|
|
value={formData.excerpt}
|
|
onChange={e => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm resize-none"
|
|
placeholder="Courte description visible dans la liste des articles..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Rich Text Editor */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Contenu *</label>
|
|
<RichTextEditor
|
|
content={formData.content}
|
|
onChange={html => setFormData(prev => ({ ...prev, content: html }))}
|
|
placeholder="Commencez à écrire votre article..."
|
|
minHeight={500}
|
|
/>
|
|
</div>
|
|
|
|
{/* SEO Panel */}
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSeoOpen(o => !o)}
|
|
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Search className="w-4 h-4 text-gray-500" />
|
|
<span className="font-semibold text-gray-900 text-sm">
|
|
SEO & Référencement
|
|
</span>
|
|
{(formData.metaTitle || formData.metaDescription || formData.primaryKeyword) && (
|
|
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
|
|
)}
|
|
</div>
|
|
{seoOpen ? (
|
|
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{seoOpen && (
|
|
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
|
|
<div className="mt-4">
|
|
<SeoPreview
|
|
metaTitle={formData.metaTitle}
|
|
metaDescription={formData.metaDescription}
|
|
slug={formData.slug}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Meta Titre
|
|
</label>
|
|
<span className={`text-xs ${metaTitleColor}`}>
|
|
{metaTitleLen}/60
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={formData.metaTitle}
|
|
onChange={e =>
|
|
setFormData(prev => ({ ...prev, metaTitle: e.target.value }))
|
|
}
|
|
maxLength={255}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Titre affiché dans Google (50-60 car. idéal)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Meta Description
|
|
</label>
|
|
<span className={`text-xs ${metaDescColor}`}>
|
|
{metaDescLen}/160
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
rows={3}
|
|
value={formData.metaDescription}
|
|
onChange={e =>
|
|
setFormData(prev => ({ ...prev, metaDescription: e.target.value }))
|
|
}
|
|
maxLength={500}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
|
placeholder="Description affichée dans Google (150-160 car. idéal)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Mot-clé principal
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.primaryKeyword}
|
|
onChange={e =>
|
|
setFormData(prev => ({ ...prev, primaryKeyword: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="ex: devis maritime instantané"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Mots-clés secondaires{' '}
|
|
<span className="text-gray-400 font-normal">(virgule)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.secondaryKeywordsInput}
|
|
onChange={e =>
|
|
setFormData(prev => ({
|
|
...prev,
|
|
secondaryKeywordsInput: e.target.value,
|
|
}))
|
|
}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="ex: fret maritime, suivi de marchandise, Click&Ship"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar — 1/3 */}
|
|
<div className="space-y-5">
|
|
{/* Publication */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
|
<h3 className="font-semibold text-gray-900">Publication</h3>
|
|
|
|
{/* Scheduled date */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1.5">
|
|
<Clock className="w-3.5 h-3.5 text-gray-400" />
|
|
Date de publication planifiée
|
|
</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={scheduledAt}
|
|
onChange={e => setScheduledAt(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
{scheduledAt && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setScheduledAt('')}
|
|
className="text-xs text-gray-400 hover:text-red-500 mt-1 underline"
|
|
>
|
|
Effacer la date
|
|
</button>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{isScheduleMode
|
|
? 'Le post sera publié automatiquement à cette date.'
|
|
: 'Laisser vide pour publier manuellement.'}
|
|
</p>
|
|
</div>
|
|
|
|
{showCreateModal ? (
|
|
<div className="space-y-2">
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={e => handleCreate(e, true)}
|
|
className="w-full py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Publier maintenant
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cover image */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
|
<h3 className="font-semibold text-gray-900 mb-3">Image de couverture</h3>
|
|
{formData.coverImageUrl ? (
|
|
<div className="relative">
|
|
<img
|
|
src={formData.coverImageUrl}
|
|
alt="Couverture"
|
|
className="w-full h-40 object-cover rounded-lg"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, coverImageUrl: '' }))}
|
|
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md text-gray-600 hover:text-red-600 transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => coverInputRef.current?.click()}
|
|
disabled={uploadingCover}
|
|
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center gap-2 text-gray-400 hover:border-blue-400 hover:text-blue-500 transition-colors disabled:opacity-50"
|
|
>
|
|
{uploadingCover ? (
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
) : (
|
|
<>
|
|
<ImageIcon className="w-6 h-6" />
|
|
<span className="text-sm">Cliquer pour uploader</span>
|
|
<span className="text-xs">JPG, PNG, WebP — max 5 Mo</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={coverInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
className="hidden"
|
|
onChange={handleCoverUpload}
|
|
/>
|
|
<div className="mt-3">
|
|
<input
|
|
type="url"
|
|
value={formData.coverImageUrl ?? ''}
|
|
onChange={e =>
|
|
setFormData(prev => ({ ...prev, coverImageUrl: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Ou coller une URL..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metadata */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
|
<h3 className="font-semibold text-gray-900">Métadonnées</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.slug}
|
|
onChange={e => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono"
|
|
placeholder="mon-article"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Catégorie *
|
|
</label>
|
|
<select
|
|
required
|
|
value={formData.category}
|
|
onChange={e =>
|
|
setFormData(prev => ({
|
|
...prev,
|
|
category: e.target.value as BlogPostCategory,
|
|
}))
|
|
}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
{CATEGORIES.map(c => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Auteur *</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.authorName}
|
|
onChange={e => setFormData(prev => ({ ...prev, authorName: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Nom de l'auteur"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Tags <span className="text-gray-400 font-normal">(virgule)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tagsInput}
|
|
onChange={e => setTagsInput(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Maritime, LCL, Export"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirm */}
|
|
{showDeleteConfirm && selectedPost && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l'article</h2>
|
|
<p className="text-gray-600 mb-6">
|
|
Êtes-vous sûr de vouloir supprimer <strong>« {selectedPost.title} »</strong>{' '}
|
|
? Cette action est irréversible.
|
|
</p>
|
|
<div className="flex justify-end space-x-3">
|
|
<button
|
|
onClick={() => {
|
|
setShowDeleteConfirm(false);
|
|
setSelectedPost(null);
|
|
}}
|
|
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|