xpeditis2.0/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
2026-06-11 19:18:56 +02:00

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&apos;article</h2>
<p className="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer <strong>«&nbsp;{selectedPost.title}&nbsp;»</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>
);
}