435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useTransition, useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
|
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2, FileJson } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import { toast } from 'react-hot-toast';
|
|
import RichTextEditor from './RichTextEditor';
|
|
import ImageUploader from './ImageUploader';
|
|
import TagInput from './TagInput';
|
|
import { ContentStatus } from '@prisma/client';
|
|
import { getAllTags } from '@/app/actions/taxonomies';
|
|
|
|
interface Props {
|
|
initialData?: {
|
|
id: string;
|
|
title: string;
|
|
excerpt: string;
|
|
content: string;
|
|
author: string;
|
|
imageUrl: string;
|
|
slug?: string | null;
|
|
tags?: string[];
|
|
metaTitle?: string | null;
|
|
metaDescription?: string | null;
|
|
publishedAt?: Date | string | null;
|
|
status?: ContentStatus;
|
|
coverPosition?: string | null;
|
|
coverZoom?: number | null;
|
|
sources?: any;
|
|
};
|
|
}
|
|
|
|
export default function BlogForm({ initialData }: Props) {
|
|
const router = useRouter();
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// JSON prefill state
|
|
const [currentData, setCurrentData] = useState<any>(initialData || {});
|
|
const [jsonString, setJsonString] = useState('');
|
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
|
|
|
// Form input states
|
|
const [content, setContent] = useState(initialData?.content || '');
|
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
|
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
|
|
const [coverZoom, setCoverZoom] = useState(initialData?.coverZoom || 1);
|
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
|
initialData?.publishedAt
|
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
|
: new Date().toISOString().slice(0, 16)
|
|
);
|
|
const [sources, setSources] = useState<{ title: string; url: string }[]>(
|
|
Array.isArray(initialData?.sources) ? initialData.sources : []
|
|
);
|
|
|
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
|
|
|
useEffect(() => {
|
|
getAllTags().then(setAllExistingTags);
|
|
}, []);
|
|
|
|
const handleApplyJson = () => {
|
|
try {
|
|
if (!jsonString.trim()) {
|
|
throw new Error("Veuillez coller un code JSON.");
|
|
}
|
|
const parsed = JSON.parse(jsonString);
|
|
|
|
// Extract new combined tags
|
|
const newTags = Array.isArray(parsed.tags) ? [...parsed.tags] : [...tags];
|
|
if (parsed.type && !newTags.includes(parsed.type)) {
|
|
newTags.push(parsed.type);
|
|
}
|
|
|
|
// Update states to prefill rich components
|
|
if (parsed.content) setContent(parsed.content);
|
|
if (parsed.imageUrl) setImageUrl(parsed.imageUrl);
|
|
setTags(newTags);
|
|
if (Array.isArray(parsed.sources)) {
|
|
setSources(parsed.sources);
|
|
}
|
|
|
|
// Update currentData to reset defaultValues on native inputs via form key remount
|
|
setCurrentData({
|
|
...currentData,
|
|
title: parsed.title || currentData.title || '',
|
|
excerpt: parsed.excerpt || currentData.excerpt || '',
|
|
author: parsed.author || currentData.author || 'Rédaction Afrohub',
|
|
slug: parsed.slug || currentData.slug || '',
|
|
metaTitle: parsed.metaTitle || currentData.metaTitle || '',
|
|
metaDescription: parsed.metaDescription || currentData.metaDescription || '',
|
|
_prefillKey: Date.now() // Force form remount
|
|
});
|
|
|
|
setJsonError(null);
|
|
setShowJsonInput(false);
|
|
setJsonString('');
|
|
toast.success("Champs pré-remplis avec le JSON !");
|
|
} catch (err: any) {
|
|
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const formData = new FormData(event.currentTarget);
|
|
const publishedAtStr = formData.get('publishedAt') as string;
|
|
|
|
const data = {
|
|
title: formData.get('title') as string,
|
|
slug: formData.get('slug') as string,
|
|
excerpt: formData.get('excerpt') as string,
|
|
content: content,
|
|
author: formData.get('author') as string,
|
|
imageUrl: imageUrl, // Use state value
|
|
tags: tags, // Use state value
|
|
metaTitle: formData.get('metaTitle') as string,
|
|
metaDescription: formData.get('metaDescription') as string,
|
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
|
status: formData.get('status') as ContentStatus,
|
|
coverPosition: coverPosition,
|
|
coverZoom: coverZoom,
|
|
sources: sources,
|
|
};
|
|
|
|
if (!data.imageUrl) {
|
|
toast.error("Une image est requise");
|
|
return;
|
|
}
|
|
|
|
startTransition(async () => {
|
|
const result = initialData
|
|
? await updateBlogPost(initialData.id, data)
|
|
: await createBlogPost(data);
|
|
|
|
if (result.success) {
|
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
|
router.push('/actualites');
|
|
router.refresh();
|
|
} else {
|
|
toast.error(result.error || "Une erreur est survenue");
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto pb-20">
|
|
<div className="mb-8 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/actualites" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</Link>
|
|
<h1 className="text-3xl font-bold text-white">
|
|
{initialData ? 'Modifier l\'article' : 'Nouvel Article'}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PREFILL JSON SECTION */}
|
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 mb-8 shadow-md">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
|
className="flex items-center justify-between w-full text-left font-bold text-sm text-brand-400 hover:text-brand-300 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<FileJson className="w-5 h-5 text-indigo-400" />
|
|
⚡ Importer JSON (Pré-remplir les champs automatiquement)
|
|
</span>
|
|
<span className="text-xs bg-slate-800 px-2.5 py-1 rounded text-slate-400 border border-slate-700">
|
|
{showJsonInput ? 'Masquer' : 'Déplier'}
|
|
</span>
|
|
</button>
|
|
|
|
{showJsonInput && (
|
|
<div className="mt-4 pt-4 border-t border-slate-800 space-y-3 animate-in fade-in duration-200">
|
|
<p className="text-xs text-slate-400">
|
|
Collez le code JSON généré par Gemini pour remplir instantanément le titre, l'extrait, le contenu HTML, l'auteur, l'image, le slug, les tags SEO, et les sources.
|
|
</p>
|
|
{jsonError && (
|
|
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 p-2.5 rounded-lg font-medium">
|
|
{jsonError}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
value={jsonString}
|
|
onChange={(e) => setJsonString(e.target.value)}
|
|
placeholder={'{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle...",\n "content": "<p>L\'année 2026 marque un tournant...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-...",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech...",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" }\n ]\n}'}
|
|
rows={8}
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-xs font-mono text-emerald-400 focus:outline-none focus:border-indigo-500"
|
|
spellCheck={false}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setJsonString('{\n "title": "Les 5 tendances de la Tech Africaine en 2026",\n "excerpt": "Découvrez comment l\'intelligence artificielle et la FinTech redéfinissent l\'écosystème entrepreneurial sur le continent cette année.",\n "content": "<p>L\'année 2026 marque un tournant décisif. Les startups...</p><h3>L\'IA au service de l\'agriculture</h3><p>De nouvelles solutions émergent...</p>",\n "author": "Rédaction Afrohub",\n "imageUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80&w=1200",\n "slug": "tendances-tech-afrique-2026",\n "tags": ["Technologie", "IA", "FinTech"],\n "metaTitle": "Les 5 tendances Tech en Afrique - Afrohub",\n "metaDescription": "Analyse complète des tendances de l\'écosystème tech et des financements sur le continent africain en 2026.",\n "sources": [\n { "title": "Rapport BAD 2026", "url": "https://afdb.org" },\n { "title": "Jeune Afrique", "url": "https://jeuneafrique.com" }\n ]\n}');
|
|
}}
|
|
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white transition-colors"
|
|
>
|
|
Exemple complet
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleApplyJson}
|
|
className="px-4 py-1.5 text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors shadow-sm"
|
|
>
|
|
Appliquer les données
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<form key={currentData._prefillKey || 'form'} onSubmit={handleSubmit} className="space-y-8">
|
|
<div className="card space-y-6">
|
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
|
<select
|
|
name="status"
|
|
defaultValue={currentData.status || 'PUBLISHED'}
|
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
|
>
|
|
<option value="DRAFT">Brouillon</option>
|
|
<option value="PUBLISHED">Publié</option>
|
|
<option value="ARCHIVED">Archivé</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Titre</label>
|
|
<input
|
|
name="title"
|
|
defaultValue={currentData.title}
|
|
required
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="Ex: L'essor de la Tech en Afrique"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Auteur</label>
|
|
<input
|
|
name="author"
|
|
defaultValue={currentData.author || (initialData ? undefined : 'Rédaction Afrohub')}
|
|
required
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="Ex: Jean Dupont"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<ImageUploader
|
|
label="Image de couverture"
|
|
value={imageUrl}
|
|
onChange={setImageUrl}
|
|
name="imageUrl"
|
|
showPositionControls={true}
|
|
position={coverPosition}
|
|
zoom={coverZoom}
|
|
onPositionChange={setCoverPosition}
|
|
onZoomChange={setCoverZoom}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">
|
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
|
</label>
|
|
<div className="relative">
|
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
|
<input
|
|
name="publishedAt"
|
|
type="datetime-local"
|
|
value={publishedAtValue}
|
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
|
|
<textarea
|
|
name="excerpt"
|
|
defaultValue={currentData.excerpt}
|
|
required
|
|
rows={2}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="Un court résumé de l'article..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
|
|
<RichTextEditor
|
|
value={content}
|
|
onChange={setContent}
|
|
placeholder="Rédigez votre article ici..."
|
|
/>
|
|
</div>
|
|
|
|
{/* SOURCES SECTION */}
|
|
<div className="pt-6 border-t border-slate-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
|
<LinkIcon className="w-4 h-4 text-brand-500" />
|
|
Sources & Liens externes
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSources([...sources, { title: '', url: '' }])}
|
|
className="text-xs font-bold text-brand-500 hover:text-brand-400 flex items-center gap-1 bg-brand-500/10 px-3 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
Ajouter une source
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{sources.map((source, index) => (
|
|
<div key={index} className="flex gap-3 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
<input
|
|
placeholder="Nom de la source (ex: Le Monde)"
|
|
value={source.title}
|
|
onChange={(e) => {
|
|
const newSources = [...sources];
|
|
newSources[index].title = e.target.value;
|
|
setSources(newSources);
|
|
}}
|
|
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
<input
|
|
placeholder="URL (ex: https://...)"
|
|
value={source.url}
|
|
onChange={(e) => {
|
|
const newSources = [...sources];
|
|
newSources[index].url = e.target.value;
|
|
setSources(newSources);
|
|
}}
|
|
className="flex-2 bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSources(sources.filter((_, i) => i !== index))}
|
|
className="p-2.5 text-slate-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{sources.length === 0 && (
|
|
<p className="text-xs text-slate-600 italic">Aucune source ajoutée pour cet article.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SEO SECTION */}
|
|
<div className="card space-y-6">
|
|
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
|
|
<input
|
|
name="slug"
|
|
defaultValue={currentData.slug || ''}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="ex: lessor-de-la-tech-afrique"
|
|
/>
|
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<TagInput
|
|
value={tags}
|
|
onChange={setTags}
|
|
suggestions={allExistingTags}
|
|
label="Mots-clés (tags)"
|
|
placeholder="tech, innovation, afrique..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
|
|
<input
|
|
name="metaTitle"
|
|
defaultValue={currentData.metaTitle || ''}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="Titre optimisé pour les moteurs de recherche"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
|
|
<textarea
|
|
name="metaDescription"
|
|
defaultValue={currentData.metaDescription || ''}
|
|
rows={3}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
) : (
|
|
<Save className="w-6 h-6" />
|
|
)}
|
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|