feat: add JSON import functionality for content creation in the admin dashboard

This commit is contained in:
2026-05-17 13:23:11 +02:00
parent 8f0dc7f38c
commit 0054f731c7
10 changed files with 907 additions and 35 deletions

2
.env
View File

@@ -5,4 +5,4 @@ DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?sch
# Exemple avec Supabase ou Neon (si base distante) # Exemple avec Supabase ou Neon (si base distante)
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres" # DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
# Environnement : "development" = accents rouges, "production" = accents normaux # Environnement : "development" = accents rouges, "production" = accents normaux
NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_APP_ENV=development

View File

@@ -0,0 +1,13 @@
import ImportJsonForm from '@/components/ImportJsonForm';
export const metadata = {
title: 'Import JSON - CMS Afrohub',
};
export default function ImportJsonPage() {
return (
<div className="py-8">
<ImportJsonForm />
</div>
);
}

View File

@@ -5,6 +5,7 @@ import DeleteActualitesButton from '@/components/DeleteActualitesButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton'; import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton'; import DeleteEventButton from '@/components/DeleteEventButton';
import ApproveContentButton from '@/components/ApproveContentButton'; import ApproveContentButton from '@/components/ApproveContentButton';
import { FileJson } from 'lucide-react';
async function getData() { async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } }); const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
@@ -50,6 +51,7 @@ export default async function ActualitesCMSPage() {
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Nouvel Événement Nouvel Événement
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
try {
// Simple API Key authentication
const apiKey = request.headers.get('x-api-key')
const expectedApiKey = process.env.EXTERNAL_API_KEY
if (!expectedApiKey) {
console.error('EXTERNAL_API_KEY is not defined in environment variables.')
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
}
if (apiKey !== expectedApiKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Validation
const { title, excerpt, content, author, imageUrl, type, tags, status, publishedAt } = body
if (!title || !excerpt || !content || !author || !imageUrl) {
return NextResponse.json({
error: 'Missing required fields: title, excerpt, content, author, imageUrl'
}, { status: 400 })
}
const rawSlug = generateSlug(title)
const slug = `${rawSlug}-${Math.random().toString(36).substring(2, 7)}`
// Si un "type" est fourni, on l'ajoute automatiquement aux tags pour le catégoriser
const finalTags = tags || []
if (type && typeof type === 'string' && !finalTags.includes(type)) {
finalTags.push(type)
}
const post = await prisma.blogPost.create({
data: {
title,
excerpt,
content,
author,
imageUrl,
slug,
tags: finalTags,
status: status || 'PUBLISHED',
publishedAt: publishedAt ? new Date(publishedAt) : new Date(Date.now() - 60000), // -1 min to ensure visibility
date: new Date()
}
})
revalidatePath('/actualites')
revalidatePath('/')
return NextResponse.json({ success: true, post }, { status: 201 })
} catch (error) {
console.error('POST /api/external/articles error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
try {
// Simple API Key authentication
const apiKey = request.headers.get('x-api-key')
const expectedApiKey = process.env.EXTERNAL_API_KEY
if (!expectedApiKey) {
console.error('EXTERNAL_API_KEY is not defined in environment variables.')
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
}
if (apiKey !== expectedApiKey) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Validation
const { title, description, date, location, thumbnailUrl, link, tags, status, publishedAt } = body
if (!title || !description || !date || !location || !thumbnailUrl) {
return NextResponse.json({
error: 'Missing required fields: title, description, date, location, thumbnailUrl'
}, { status: 400 })
}
const rawSlug = generateSlug(title)
const slug = `${rawSlug}-${Math.random().toString(36).substring(2, 7)}`
const event = await prisma.event.create({
data: {
title,
description,
date: new Date(date),
location,
thumbnailUrl,
link,
slug,
tags: tags || [],
status: status || 'PUBLISHED',
publishedAt: publishedAt ? new Date(publishedAt) : new Date()
}
})
revalidatePath('/evenements')
revalidatePath('/')
return NextResponse.json({ success: true, event }, { status: 201 })
} catch (error) {
console.error('POST /api/external/events error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/actualites'; import { createBlogPost, updateBlogPost } from '@/app/actions/actualites';
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2 } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive, Link as LinkIcon, Plus, Trash2, FileJson } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
@@ -35,6 +35,14 @@ interface Props {
export default function BlogForm({ initialData }: Props) { export default function BlogForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); 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 [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || ''); const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%'); const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
@@ -56,6 +64,48 @@ export default function BlogForm({ initialData }: Props) {
getAllTags().then(setAllExistingTags); 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>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@@ -111,7 +161,63 @@ export default function BlogForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> {/* 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="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4"> <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> <h2 className="text-xl font-semibold text-white">Informations Générales</h2>
@@ -119,7 +225,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select <select
name="status" name="status"
defaultValue={initialData?.status || 'PUBLISHED'} 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" 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="DRAFT">Brouillon</option>
@@ -134,7 +240,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre</label> <label className="text-sm font-medium text-slate-400">Titre</label>
<input <input
name="title" name="title"
defaultValue={initialData?.title} defaultValue={currentData.title}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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" placeholder="Ex: L'essor de la Tech en Afrique"
@@ -144,7 +250,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Auteur</label> <label className="text-sm font-medium text-slate-400">Auteur</label>
<input <input
name="author" name="author"
defaultValue={initialData?.author} defaultValue={currentData.author || (initialData ? undefined : 'Rédaction Afrohub')}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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" placeholder="Ex: Jean Dupont"
@@ -187,7 +293,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label> <label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<textarea <textarea
name="excerpt" name="excerpt"
defaultValue={initialData?.excerpt} defaultValue={currentData.excerpt}
required required
rows={2} 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" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
@@ -269,7 +375,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label> <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input <input
name="slug" name="slug"
defaultValue={initialData?.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" 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" placeholder="ex: lessor-de-la-tech-afrique"
/> />
@@ -290,7 +396,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="metaTitle" name="metaTitle"
defaultValue={initialData?.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" 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" placeholder="Titre optimisé pour les moteurs de recherche"
/> />
@@ -300,7 +406,7 @@ export default function BlogForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea <textarea
name="metaDescription" name="metaDescription"
defaultValue={initialData?.metaDescription || ''} defaultValue={currentData.metaDescription || ''}
rows={3} 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" 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)..." placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event'; import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send, FileJson } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
@@ -19,6 +19,14 @@ interface Props {
export default function EventForm({ initialData }: Props) { export default function EventForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); 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 [description, setDescription] = useState(initialData?.description || ''); const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%'); const [coverPosition, setCoverPosition] = useState(initialData?.coverPosition || '50% 50%');
@@ -37,6 +45,46 @@ export default function EventForm({ initialData }: Props) {
getAllTags().then(setAllExistingTags); 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.description) setDescription(parsed.description);
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
setTags(newTags);
// Update currentData to reset defaultValues on native inputs via form key remount
setCurrentData({
...currentData,
title: parsed.title || currentData.title || '',
location: parsed.location || currentData.location || '',
date: parsed.date || currentData.date || '',
link: parsed.link || parsed.registrationUrl || currentData.link || '',
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 de l'événement pré-remplis !");
} catch (err: any) {
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
@@ -79,6 +127,17 @@ export default function EventForm({ initialData }: Props) {
}); });
}; };
const getDefaultDateStr = () => {
if (currentData.date) {
try {
return new Date(currentData.date).toISOString().slice(0, 16);
} catch(e) {
return '';
}
}
return '';
};
return ( return (
<div className="max-w-4xl mx-auto pb-20"> <div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
@@ -92,7 +151,63 @@ export default function EventForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> {/* 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-amber-400" />
Importer JSON (Pré-remplir les champs de l'événement 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, la date, le lieu, l'affiche, l'URL d'inscription, la description, le slug et les attributs SEO.
</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": "Afro Tech Summit 2026",\n "description": "...",\n "date": "2026-09-15T09:00:00Z",\n "location": "...",\n "thumbnailUrl": "...",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali",\n "metaDescription": "Participez au sommet d\'innovation..."\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-amber-500"
spellCheck={false}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setJsonString('{\n "title": "Afro Tech Summit 2026",\n "description": "<p>Le plus grand rassemblement des innovateurs de la tech africaine avec plus de 500 startups attendues.</p>",\n "date": "2026-09-15T09:00:00Z",\n "location": "Kigali Convention Centre, Rwanda",\n "thumbnailUrl": "https://images.unsplash.com/photo-1540575467063-178a50c2df87",\n "link": "https://afrotechsummit2026.com",\n "slug": "afro-tech-summit-2026",\n "tags": ["Conférence", "Tech", "Rwanda"],\n "metaTitle": "Afro Tech Summit 2026 - Kigali, Rwanda",\n "metaDescription": "Rejoignez l\'élite de la tech africaine au Kigali Convention Centre pour 3 jours d\'échanges et d\'opportunités d\'investissement."\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-amber-600 hover:bg-amber-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="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4"> <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> <h2 className="text-xl font-semibold text-white">Informations Générales</h2>
@@ -100,7 +215,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select <select
name="status" name="status"
defaultValue={initialData?.status || 'PUBLISHED'} 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" 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="DRAFT">Brouillon</option>
@@ -115,7 +230,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label> <label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
<input <input
name="title" name="title"
defaultValue={initialData?.title} defaultValue={currentData.title}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Conférence AfroHub 2026" placeholder="Ex: Conférence AfroHub 2026"
@@ -128,7 +243,7 @@ export default function EventForm({ initialData }: Props) {
<input <input
name="date" name="date"
type="datetime-local" type="datetime-local"
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''} defaultValue={getDefaultDateStr()}
required required
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" 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"
/> />
@@ -143,7 +258,7 @@ export default function EventForm({ initialData }: Props) {
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" /> <MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input <input
name="location" name="location"
defaultValue={initialData?.location} defaultValue={currentData.location}
required required
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" 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"
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom" placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
@@ -187,7 +302,7 @@ export default function EventForm({ initialData }: Props) {
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" /> <LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input <input
name="link" name="link"
defaultValue={initialData?.link} defaultValue={currentData.link}
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" 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"
placeholder="https://..." placeholder="https://..."
/> />
@@ -214,7 +329,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label> <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input <input
name="slug" name="slug"
defaultValue={initialData?.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" 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: conference-afrohub-2026" placeholder="ex: conference-afrohub-2026"
/> />
@@ -235,7 +350,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="metaTitle" name="metaTitle"
defaultValue={initialData?.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" 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 Google" placeholder="Titre optimisé pour Google"
/> />
@@ -245,7 +360,7 @@ export default function EventForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea <textarea
name="metaDescription" name="metaDescription"
defaultValue={initialData?.metaDescription || ''} defaultValue={currentData.metaDescription || ''}
rows={3} 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" 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 courte pour les résultats de recherche..." placeholder="Description courte pour les résultats de recherche..."

View File

@@ -0,0 +1,393 @@
"use client";
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost } from '@/app/actions/actualites';
import { createEvent } from '@/app/actions/event';
import { createInterview } from '@/app/actions/interview';
import { FileJson, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import toast from 'react-hot-toast';
const articleExample = `{
"title": "Les 5 tendances de la Tech Africaine en 2026",
"excerpt": "Découvrez comment l'intelligence artificielle et la FinTech redéfinissent l'écosystème entrepreneurial sur le continent cette année.",
"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>",
"author": "Rédaction Afrohub",
"imageUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80&w=1200",
"type": "Technologie"
}`;
const eventExample = `{
"title": "Afro Tech Summit 2026",
"description": "<p>Le plus grand rassemblement des innovateurs de la tech africaine.</p>",
"date": "2026-09-15T09:00:00Z",
"location": "Kigali Convention Centre, Rwanda",
"thumbnailUrl": "https://images.unsplash.com/photo-1540575467063-178a50c2df87",
"type": "Conférence",
"link": "https://afrotechsummit2026.com"
}`;
const interviewExample = `{
"title": "Comment nous avons levé 2M€ pour révolutionner le transport",
"guestName": "Aminata Diallo",
"companyName": "GoAfrica Logistique",
"role": "CEO & Fondatrice",
"type": "TEXT",
"excerpt": "Entretien exclusif avec Aminata Diallo sur les défis de la chaîne d'approvisionnement en Afrique de l'Ouest.",
"content": "<p><strong>Afrohub : Qu'est-ce qui vous a poussée à lancer GoAfrica ?</strong></p><p>Aminata : Le constat était simple...</p>",
"thumbnailUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=1200",
"tags": ["Logistique", "Interview", "Sénégal"]
}`;
export default function ImportJsonForm() {
const router = useRouter();
const [jsonInput, setJsonInput] = useState('');
const [importType, setImportType] = useState<'article' | 'event' | 'interview'>('article');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonInput);
setJsonInput(JSON.stringify(parsed, null, 2));
setError(null);
} catch (e) {
setError("Le JSON actuel est mal formaté. Impossible de le formater.");
}
};
const handleImport = async () => {
setError(null);
setSuccess(null);
setLoading(true);
try {
if (!jsonInput.trim()) {
throw new Error("Le champ JSON est vide.");
}
let parsedData;
try {
parsedData = JSON.parse(jsonInput);
} catch (e) {
throw new Error("Format JSON invalide. Veuillez vérifier la syntaxe (guillemets, virgules, etc).");
}
if (importType === 'article') {
const required = ['title', 'excerpt', 'content', 'author', 'imageUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.type && !tags.includes(parsedData.type)) {
tags.push(parsedData.type);
}
const result = await createBlogPost({
title: parsedData.title,
excerpt: parsedData.excerpt,
content: parsedData.content,
author: parsedData.author,
imageUrl: parsedData.imageUrl,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Article importé avec succès !");
setSuccess("L'article a été importé et publié avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'article.");
}
} else if (importType === 'event') {
const required = ['title', 'description', 'date', 'location', 'thumbnailUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.type && !tags.includes(parsedData.type)) {
tags.push(parsedData.type);
}
const result = await createEvent({
title: parsedData.title,
description: parsedData.description,
date: new Date(parsedData.date),
location: parsedData.location,
thumbnailUrl: parsedData.thumbnailUrl,
link: parsedData.link || parsedData.registrationUrl,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Événement importé avec succès !");
setSuccess("L'événement a été importé et publié avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'événement.");
}
} else {
// Validation Interview
const required = ['title', 'guestName', 'companyName', 'role', 'type', 'excerpt', 'thumbnailUrl'];
const missing = required.filter(field => !parsedData[field]);
if (missing.length > 0) {
throw new Error("Il manque les champs obligatoires suivants : " + missing.join(', '));
}
const tags = parsedData.tags || [];
if (parsedData.typeTag && !tags.includes(parsedData.typeTag)) {
tags.push(parsedData.typeTag);
}
const result = await createInterview({
title: parsedData.title,
guestName: parsedData.guestName,
companyName: parsedData.companyName,
role: parsedData.role,
type: parsedData.type as any, // 'TEXT' | 'VIDEO'
excerpt: parsedData.excerpt,
content: parsedData.content || '',
thumbnailUrl: parsedData.thumbnailUrl,
videoUrl: parsedData.videoUrl,
duration: parsedData.duration,
tags: tags,
publishedAt: parsedData.publishedAt ? new Date(parsedData.publishedAt) : new Date(Date.now() - 60000),
});
if (result.success) {
toast.success("Interview importée avec succès !");
setSuccess("L'interview a été importée et publiée avec succès.");
setJsonInput('');
} else {
throw new Error(result.error || "Erreur lors de la création de l'interview.");
}
}
router.refresh();
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'importation.");
} finally {
setLoading(false);
}
};
const loadExample = () => {
if (importType === 'article') setJsonInput(articleExample);
else if (importType === 'event') setJsonInput(eventExample);
else setJsonInput(interviewExample);
setError(null);
setSuccess(null);
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<Link href="/actualites" className="text-slate-400 hover:text-white flex items-center gap-2 text-sm font-medium w-fit">
<ArrowLeft className="w-4 h-4" />
Retour au CMS
</Link>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
<div className="p-6 border-b border-slate-800 bg-slate-800/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/20 text-indigo-400 rounded-lg">
<FileJson className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-white">Importateur JSON</h1>
<p className="text-sm text-slate-400">Importez directement vos contenus depuis un format JSON valide.</p>
</div>
</div>
<div className="flex bg-slate-900 p-1 rounded-lg border border-slate-700 flex-wrap gap-1">
<button
onClick={() => { setImportType('article'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'article' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Article
</button>
<button
onClick={() => { setImportType('interview'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'interview' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Interview
</button>
<button
onClick={() => { setImportType('event'); setJsonInput(''); setError(null); setSuccess(null); }}
className={"px-3 py-1.5 rounded-md text-xs font-bold transition-all " + (importType === 'event' ? 'bg-amber-600 text-white shadow-sm' : 'text-slate-400 hover:text-white')}
>
Événement
</button>
</div>
</div>
<div className="p-6">
{error && (
<div className="mb-6 bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-start gap-3 text-red-400">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<h3 className="font-bold">Erreur de formatage ou d'import</h3>
<p className="text-sm mt-1">{error}</p>
</div>
</div>
)}
{success && (
<div className="mb-6 bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-4 flex items-start gap-3 text-emerald-400">
<CheckCircle2 className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<h3 className="font-bold">Succès</h3>
<p className="text-sm mt-1">{success}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 flex flex-col">
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-slate-300">Code JSON</label>
<div className="flex gap-3">
<button onClick={loadExample} className="text-xs text-brand-400 hover:text-brand-300 underline font-medium">
Charger un exemple
</button>
<button onClick={handleFormatJson} className="text-xs text-slate-400 hover:text-white underline font-medium">
Formater le code
</button>
</div>
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-[400px] bg-slate-950 border border-slate-700 rounded-xl p-4 text-emerald-400 font-mono text-sm focus:border-brand-500 focus:ring-1 focus:ring-brand-500 outline-none resize-none"
placeholder="Collez votre JSON ici..."
spellCheck="false"
/>
<p className="text-xs text-slate-500 mt-2">
Assurez-vous que les guillemets soient des guillemets droits (") et non des guillemets typographiques (« » ou “ ”).
</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-5 h-fit">
<h3 className="font-bold text-white mb-4">Structure attendue</h3>
{importType === 'article' ? (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">excerpt</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">content</span>
<span className="text-slate-400">Requis (HTML)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">author</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">imageUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">type</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
) : importType === 'interview' ? (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">guestName</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">companyName</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">role</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">type</span>
<span className="text-slate-400">TEXT / VIDEO</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">thumbnailUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">videoUrl</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
) : (
<ul className="space-y-3 text-sm">
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">title</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">description</span>
<span className="text-slate-400">Requis (HTML)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">date</span>
<span className="text-slate-400">Requis (ISO)</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">location</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between border-b border-slate-700/50 pb-2">
<span className="text-brand-400 font-mono">thumbnailUrl</span>
<span className="text-slate-400">Requis</span>
</li>
<li className="flex justify-between pb-2">
<span className="text-slate-400 font-mono">link</span>
<span className="text-slate-500">Optionnel</span>
</li>
</ul>
)}
</div>
</div>
</div>
<div className="p-6 border-t border-slate-800 bg-slate-900 flex justify-end">
<button
onClick={handleImport}
disabled={loading}
className="bg-brand-600 hover:bg-brand-700 text-white px-8 py-2.5 rounded-lg font-bold transition-colors disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>Importation en cours...</>
) : (
<>Importer le contenu</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useTransition, useState, useEffect } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview'; import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Video, FileText, Calendar, FileJson } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { InterviewType, ContentStatus } from '@prisma/client'; import { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -19,6 +19,14 @@ interface Props {
export default function InterviewForm({ initialData }: Props) { export default function InterviewForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); 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 [content, setContent] = useState(initialData?.content || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []); const [tags, setTags] = useState<string[]>(initialData?.tags || []);
@@ -35,6 +43,50 @@ export default function InterviewForm({ initialData }: Props) {
getAllTags().then(setAllExistingTags); 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.typeTag && !newTags.includes(parsed.typeTag)) {
newTags.push(parsed.typeTag);
}
// Update states to prefill rich components
if (parsed.content) setContent(parsed.content);
if (parsed.thumbnailUrl) setThumbnailUrl(parsed.thumbnailUrl);
setTags(newTags);
// Update currentData to reset defaultValues on native inputs via form key remount
setCurrentData({
...currentData,
title: parsed.title || currentData.title || '',
guestName: parsed.guestName || currentData.guestName || '',
companyName: parsed.companyName || currentData.companyName || '',
role: parsed.role || currentData.role || '',
type: parsed.type || currentData.type || 'TEXT',
videoUrl: parsed.videoUrl || currentData.videoUrl || '',
duration: parsed.duration || currentData.duration || '',
excerpt: parsed.excerpt || currentData.excerpt || '',
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 d'interview pré-remplis !");
} catch (err: any) {
setJsonError("Format JSON invalide. Vérifiez la syntaxe : " + (err.message || ''));
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@@ -92,7 +144,63 @@ export default function InterviewForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> {/* 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-emerald-400" />
Importer JSON (Pré-remplir les champs d'interview 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 les infos de l'invité, l'extrait, la miniature, la transcription, le slug et les balises SEO.
</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": "Comment nous avons levé 2M€...",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "excerpt": "Entretien exclusif...",\n "thumbnailUrl": "...",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez le parcours inspirant d\'Aminata Diallo..."\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-emerald-500"
spellCheck={false}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setJsonString('{\n "title": "Comment nous avons levé 2M€ pour révolutionner le transport",\n "guestName": "Aminata Diallo",\n "companyName": "GoAfrica Logistique",\n "role": "CEO & Fondatrice",\n "type": "TEXT",\n "excerpt": "Entretien exclusif avec Aminata Diallo sur les défis de la chaîne d\'approvisionnement en Afrique de l\'Ouest.",\n "content": "<p><strong>Afrohub : Qu\'est-ce qui vous a poussée à lancer GoAfrica ?</strong></p><p>Aminata : Le constat était simple...</p>",\n "thumbnailUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=1200",\n "slug": "interview-aminata-diallo-goafrica",\n "tags": ["Logistique", "Interview", "Sénégal"],\n "metaTitle": "Interview Aminata Diallo - GoAfrica Logistique",\n "metaDescription": "Découvrez les secrets de la levée de fonds de 2M€ par GoAfrica Logistique dans cet entretien exclusif avec sa fondatrice."\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-emerald-600 hover:bg-emerald-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="card space-y-6">
<div className="flex items-center justify-between border-b border-slate-800 pb-4"> <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> <h2 className="text-xl font-semibold text-white">Informations Générales</h2>
@@ -100,7 +208,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select <select
name="status" name="status"
defaultValue={initialData?.status || 'PUBLISHED'} 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" 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="DRAFT">Brouillon</option>
@@ -115,7 +223,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label> <label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
<input <input
name="title" name="title"
defaultValue={initialData?.title} defaultValue={currentData.title}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Entreprise X : Une success story..." placeholder="Ex: Entreprise X : Une success story..."
@@ -125,7 +233,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label> <label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
<input <input
name="guestName" name="guestName"
defaultValue={initialData?.guestName} defaultValue={currentData.guestName}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Sarah Koné" placeholder="Ex: Sarah Koné"
@@ -135,7 +243,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Entreprise</label> <label className="text-sm font-medium text-slate-400">Entreprise</label>
<input <input
name="companyName" name="companyName"
defaultValue={initialData?.companyName} defaultValue={currentData.companyName}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: TechAfrica" placeholder="Ex: TechAfrica"
@@ -145,7 +253,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label> <label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input <input
name="role" name="role"
defaultValue={initialData?.role} defaultValue={currentData.role}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: Fondatrice & CEO" placeholder="Ex: Fondatrice & CEO"
@@ -158,11 +266,11 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Type d'interview</label> <label className="text-sm font-medium text-slate-400">Type d'interview</label>
<select <select
name="type" name="type"
defaultValue={initialData?.type || 'VIDEO'} defaultValue={currentData.type || 'VIDEO'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
> >
<option value="VIDEO">Vidéo</option> <option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option> <option value="TEXT">Article (Texte)</option>
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -195,7 +303,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label> <label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input <input
name="videoUrl" name="videoUrl"
defaultValue={initialData?.videoUrl} defaultValue={currentData.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..." placeholder="https://youtube.com/..."
/> />
@@ -203,7 +311,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label> <label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input <input
name="duration" name="duration"
defaultValue={initialData?.duration} defaultValue={currentData.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" 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: 12 min" placeholder="Ex: 12 min"
/> />
@@ -215,7 +323,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label> <label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
<textarea <textarea
name="excerpt" name="excerpt"
defaultValue={initialData?.excerpt} defaultValue={currentData.excerpt}
required required
rows={2} 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" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
@@ -241,7 +349,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label> <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input <input
name="slug" name="slug"
defaultValue={initialData?.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" 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: interview-sarah-kone-techafrica" placeholder="ex: interview-sarah-kone-techafrica"
/> />
@@ -262,7 +370,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="metaTitle" name="metaTitle"
defaultValue={initialData?.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" 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é SEO" placeholder="Titre optimisé SEO"
/> />
@@ -272,7 +380,7 @@ export default function InterviewForm({ initialData }: Props) {
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea <textarea
name="metaDescription" name="metaDescription"
defaultValue={initialData?.metaDescription || ''} defaultValue={currentData.metaDescription || ''}
rows={3} 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" 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 courte SEO..." placeholder="Description courte SEO..."

13
check-posts.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const posts = await prisma.blogPost.findMany({
orderBy: { createdAt: 'desc' },
take: 5
});
console.log(JSON.stringify(posts, null, 2));
}
main().catch(console.error).finally(() => prisma.$disconnect());