feat: implement comprehensive CMS dashboard for managing news, events, interviews, and one-shot pricing plans
This commit is contained in:
236
app/actualites/[id]/page.tsx
Normal file
236
app/actualites/[id]/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||
import { prisma } from '../../../lib/prisma';
|
||||
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// Style spécifique pour empêcher strictement la coupure des mots tout en justifiant le texte
|
||||
const noBreakStyle = `
|
||||
#blog-content,
|
||||
#blog-content p,
|
||||
#blog-content li,
|
||||
#blog-content span,
|
||||
#blog-content strong {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
hyphens: none !important;
|
||||
line-break: normal !important;
|
||||
text-align: justify !important;
|
||||
text-justify: inter-word !important;
|
||||
}
|
||||
|
||||
/* Sécurité supplémentaire pour les navigateurs mobiles */
|
||||
.prose * {
|
||||
word-break: normal !important;
|
||||
hyphens: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [{ id: id }, { slug: id }],
|
||||
publishedAt: { lte: new Date() },
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) return { title: 'Article non trouvé' };
|
||||
|
||||
return {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
keywords: post.tags,
|
||||
openGraph: {
|
||||
title: post.metaTitle || post.title,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
images: [post.imageUrl],
|
||||
type: 'article',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const post = await prisma.blogPost.findFirst({
|
||||
where: {
|
||||
OR: [{ id: id }, { slug: id }],
|
||||
publishedAt: { lte: new Date() },
|
||||
status: 'PUBLISHED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Articles en rapport : par tags communs, sinon les plus récents
|
||||
let relatedPosts = await prisma.blogPost.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: { lte: new Date() },
|
||||
id: { not: post.id },
|
||||
tags: { hasSome: post.tags }
|
||||
},
|
||||
take: 3,
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
|
||||
// Compléter avec les articles récents si nécessaire
|
||||
if (relatedPosts.length < 2) {
|
||||
const excludedIds = [post.id, ...relatedPosts.map(p => p.id)];
|
||||
const additionalPosts = await prisma.blogPost.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: { lte: new Date() },
|
||||
id: { notIn: excludedIds }
|
||||
},
|
||||
take: 3 - relatedPosts.length,
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
relatedPosts = [...relatedPosts, ...additionalPosts];
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-gray-50 min-h-screen py-12 relative">
|
||||
{/* Injection du style de force pour le texte */}
|
||||
<style dangerouslySetInnerHTML={{ __html: noBreakStyle }} />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 flex gap-4 lg:gap-8 justify-center relative">
|
||||
{/* Conteneur principal de l'article */}
|
||||
<div className="w-[calc(100%-3rem)] sm:w-full max-w-3xl relative">
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mb-8">
|
||||
<Link href="/actualites" className="inline-flex items-center text-gray-500 hover:text-brand-600 text-sm font-medium transition-colors">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour aux articles
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 md:p-10 border border-gray-100 relative z-10">
|
||||
{/* Image d'en-tête */}
|
||||
<div className="w-full h-64 md:h-80 relative mb-8 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header de l'article */}
|
||||
<header className="mb-8 border-b border-gray-100 pb-8">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<span className="flex items-center">
|
||||
<User className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1 text-brand-500" />
|
||||
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{post.tags.length > 0 ? (
|
||||
post.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
|
||||
Article
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Contenu de l'article */}
|
||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||
{/* Résumé / Lead */}
|
||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Corps du texte avec ID pour le contrôle CSS */}
|
||||
<div
|
||||
id="blog-content"
|
||||
className="mt-6"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Partage / Footer */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-100 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-gray-500 font-medium">Vous avez aimé cet article ?</p>
|
||||
<Link
|
||||
href="/actualites"
|
||||
className="inline-flex items-center text-brand-600 hover:text-brand-700 font-semibold transition-colors"
|
||||
>
|
||||
Lire d'autres articles
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar flottante verticale à droite (suit l'article et s'arrête à la fin) */}
|
||||
<div className="w-10 shrink-0 relative">
|
||||
<div className="sticky top-32 z-50">
|
||||
<BlogShareButtons title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Section Articles en rapport */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 pt-16 border-t border-gray-200">
|
||||
<h2 className="text-2xl md:text-3xl font-serif font-bold text-gray-900 mb-8">Articles en rapport</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedPosts.map((rPost) => (
|
||||
<Link
|
||||
key={rPost.id}
|
||||
href={`/actualites/${rPost.slug || rPost.id}`}
|
||||
className="group bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-video relative overflow-hidden">
|
||||
<img
|
||||
src={rPost.imageUrl}
|
||||
alt={rPost.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors" />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-600 bg-brand-50 px-2 py-0.5 rounded">
|
||||
{rPost.tags?.[0] || 'Article'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(rPost.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors line-clamp-2 leading-snug">
|
||||
{rPost.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 line-clamp-2">
|
||||
{rPost.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
82
app/actualites/page.tsx
Normal file
82
app/actualites/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
export default async function BlogPage() {
|
||||
let posts: any[] = [];
|
||||
|
||||
try {
|
||||
posts = await prisma.blogPost.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: {
|
||||
lte: new Date()
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database connection failed on blog page.");
|
||||
posts = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold font-serif text-gray-900 sm:text-4xl">Nos Actualités</h1>
|
||||
<p className="mt-3 max-w-2xl mx-auto text-xl text-gray-500 sm:mt-4">
|
||||
Conseils, dossiers et news de l'écosystème africain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
|
||||
<p className="text-gray-500">Aucun article n'a encore été publié.</p>
|
||||
<p className="text-sm text-gray-400 mt-2">Revenez bientôt pour de nouveaux contenus !</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{posts.map(post => (
|
||||
<Link key={post.id} href={`/actualites/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
|
||||
<div className="flex-shrink-0 relative overflow-hidden h-48">
|
||||
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
|
||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>
|
||||
</div>
|
||||
<div className="flex-1 bg-white p-6 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-brand-600">Actualité</p>
|
||||
<div className="block mt-2">
|
||||
<p className="text-xl font-semibold text-gray-900 group-hover:text-brand-700 transition-colors line-clamp-2">{post.title}</p>
|
||||
<p className="mt-3 text-base text-gray-500 line-clamp-3">{post.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-brand-50 flex items-center justify-center text-brand-600 font-bold border border-brand-100 uppercase">
|
||||
{post.author.charAt(0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">{post.author}</p>
|
||||
<div className="flex space-x-1 text-sm text-gray-500">
|
||||
<time>{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-brand-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user