xpeditis2.0/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
2026-05-12 21:01:52 +02:00

679 lines
25 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,
} 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',
published: 'Publié',
archived: 'Archivé',
};
const STATUS_COLORS: Record<BlogPostStatus, string> = {
draft: 'bg-yellow-100 text-yellow-800',
published: 'bg-green-100 text-green-800',
archived: 'bg-gray-100 text-gray-600',
};
const EMPTY_FORM: CreateBlogPostRequest = {
title: '',
slug: '',
excerpt: '',
content: '',
coverImageUrl: '',
category: 'industry',
tags: [],
authorName: '',
};
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
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<CreateBlogPostRequest>(EMPTY_FORM);
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 handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await createBlogPost({
...formData,
tags: tagsInput
? tagsInput
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [],
});
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 = {
...formData,
status: editStatus,
tags: tagsInput
? tagsInput
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [],
};
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('');
setEditStatus('draft');
setSelectedPost(null);
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,
});
setTagsInput(post.tags.join(', '));
setEditStatus(post.status);
setShowEditModal(true);
};
const closeModal = () => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedPost(null);
setFormData(EMPTY_FORM);
setTagsInput('');
};
const handleTitleChange = (title: string) => {
setFormData(prev => ({
...prev,
title,
slug:
prev.slug === generateSlug(prev.title) || prev.slug === ''
? generateSlug(title)
: prev.slug,
}));
};
const isOpen = showCreateModal || showEditModal;
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">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_COLORS[post.status]}`}
>
{STATUS_LABELS[post.status]}
</span>
</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' && (
<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="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>
</div>
{/* Sidebar — 1/3 */}
<div className="space-y-5">
{/* Publish button */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-4">Publication</h3>
<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"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving
? 'Enregistrement...'
: showCreateModal
? 'Créer le brouillon'
: 'Enregistrer'}
</button>
{showCreateModal && (
<p className="text-xs text-gray-400 text-center mt-2">
Créé en brouillon. Publiez depuis la liste.
</p>
)}
</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}
/>
{/* Also allow URL */}
<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>
);
}