synchronisation du contenu avec la DB et amélioration du CMS

- Migration du blog et des interviews des données mockées vers PostgreSQL (Prisma).
- Refonte des pages publiques en Server Components pour de meilleures performances/SEO.
- Intégration d'un éditeur de texte riche (React Quill) avec titres H1-H6 et séparateurs.
- Correction des erreurs de validation Prisma liées aux paramètres asynchrones (Next.js 15+).
- Correction des problèmes d'affichage des modales de suspension via React Portals.
- Installation de @tailwindcss/typography et correction du débordement de texte (break-words).
- Implémentation des actions de modération (suspension/révocation) pour les utilisateurs et boutiques.
This commit is contained in:
2026-04-12 18:59:20 +02:00
parent b73939d381
commit 887030ee47
84 changed files with 5577 additions and 15128 deletions

View File

@@ -1,11 +1,12 @@
"use client";
import { useTransition } from 'react';
import { useTransition, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
import { Loader2, ArrowLeft, Save } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
interface Props {
initialData?: {
@@ -21,6 +22,7 @@ interface Props {
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -28,7 +30,7 @@ export default function BlogForm({ initialData }: Props) {
const data = {
title: formData.get('title') as string,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string,
content: content,
author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string,
};
@@ -110,12 +112,9 @@ export default function BlogForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<textarea
name="content"
defaultValue={initialData?.content}
required
rows={12}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500 font-serif leading-relaxed"
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/>
</div>

View File

@@ -0,0 +1,67 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
interface ClientActionsProps {
userId: string;
userName: string;
isSuspended: boolean;
}
export default function ClientActions({ userId, userName, isSuspended }: ClientActionsProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleSuspend = async (reason: string) => {
const result = await suspendUser(userId, reason);
if (result.success) {
toast.success(`${userName} a été suspendu.`);
} else {
toast.error(result.error || "Échec de la suspension");
}
};
const handleUnsuspend = async () => {
const result = await unsuspendUser(userId);
if (result.success) {
toast.success(`${userName} a été réactivé.`);
} else {
toast.error(result.error || "Échec de la réactivation");
}
};
if (isSuspended) {
return (
<button
onClick={handleUnsuspend}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-green-500/20"
>
<ShieldCheck className="w-3 h-3" />
Réactiver
</button>
);
}
return (
<>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-rose-500/20"
>
<ShieldAlert className="w-3 h-3" />
Suspendre
</button>
<SuspensionModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleSuspend}
title="Suspendre le compte"
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
/>
</>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck, ShoppingBag, User } from 'lucide-react';
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
interface EntrepreneurActionsProps {
businessId: string;
businessName: string;
userId: string;
userName: string;
isBusinessSuspended: boolean;
isUserSuspended: boolean;
}
export default function EntrepreneurActions({
businessId,
businessName,
userId,
userName,
isBusinessSuspended,
isUserSuspended
}: EntrepreneurActionsProps) {
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
const handleConfirm = async (reason: string) => {
if (modalType === 'USER') {
const result = await suspendUser(userId, reason);
if (result.success) toast.success(`Compte de ${userName} suspendu.`);
else toast.error(result.error || "Erreur");
} else if (modalType === 'BUSINESS') {
const result = await suspendBusiness(businessId, reason);
if (result.success) toast.success(`Boutique ${businessName} suspendue.`);
else toast.error(result.error || "Erreur");
}
setModalType(null);
};
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 justify-end">
{/* User Suspension */}
{isUserSuspended ? (
<button
onClick={() => unsuspendUser(userId)}
className="btn-action bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
title="Activer le compte"
>
<ShieldCheck className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Compte</span>
</button>
) : (
<button
onClick={() => setModalType('USER')}
className="btn-action bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white"
title="Suspendre le compte complet"
>
<User className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Compte</span>
</button>
)}
{/* Business Suspension */}
{isBusinessSuspended ? (
<button
onClick={() => unsuspendBusiness(businessId)}
className="btn-action bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white"
title="Activer la boutique"
>
<ShieldCheck className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Shop</span>
</button>
) : (
<button
onClick={() => setModalType('BUSINESS')}
className="btn-action bg-orange-500/10 text-orange-500 hover:bg-orange-500 hover:text-white"
title="Suspendre uniquement la boutique"
>
<ShoppingBag className="w-4 h-4" />
<span className="text-[10px] uppercase font-bold">Shop</span>
</button>
)}
</div>
<SuspensionModal
isOpen={modalType !== null}
onClose={() => setModalType(null)}
onConfirm={handleConfirm}
title={modalType === 'USER' ? "Suspendre l'utilisateur" : "Suspendre la boutique"}
subtitle={modalType === 'USER'
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
/>
</div>
);
}

View File

@@ -1,12 +1,13 @@
"use client";
import { useTransition } from 'react';
import { useTransition, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
import Link from 'next/link';
import { InterviewType } from '@prisma/client';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
interface Props {
initialData?: any;
@@ -15,6 +16,7 @@ interface Props {
export default function InterviewForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -28,7 +30,7 @@ export default function InterviewForm({ initialData }: Props) {
thumbnailUrl: formData.get('thumbnailUrl') as string,
videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string || null,
content: content || null,
duration: formData.get('duration') as string || null,
};
@@ -161,11 +163,10 @@ export default function InterviewForm({ initialData }: Props) {
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
<textarea
name="content"
defaultValue={initialData?.content}
rows={8}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez la transcription ou le contenu ici..."
/>
</div>

View File

@@ -0,0 +1,62 @@
"use client";
import React, { useState } from 'react';
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
import { toast } from 'react-hot-toast';
import SuspensionModal from './SuspensionModal';
interface ModerationSuspensionButtonProps {
userId: string;
userName: string;
isSuspended: boolean;
}
export default function ModerationSuspensionButton({
userId,
userName,
isSuspended
}: ModerationSuspensionButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleSuspend = async (reason: string) => {
const result = await suspendUser(userId, reason);
if (result.success) {
toast.success(`${userName} a été suspendu.`);
} else {
toast.error(result.error || "Échec de la suspension");
}
};
if (isSuspended) {
return (
<button
onClick={() => unsuspendUser(userId)}
className="flex items-center gap-1.5 px-3 py-2 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-xs font-bold"
>
<ShieldCheck className="w-4 h-4" />
Réactiver le compte de l'auteur
</button>
);
}
return (
<>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1.5 px-3 py-2 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-xs font-bold"
>
<ShieldAlert className="w-4 h-4" />
Suspendre l'auteur du message
</button>
<SuspensionModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleSuspend}
title="Suspendre l'auteur"
subtitle={`Voulez-vous suspendre le compte de ${userName} pour ce message non conforme ?`}
/>
</>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useTransition } from 'react';
import { updateReportStatus } from '@/app/actions/moderation';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
reportId: string;
}
export default function ReportActionButtons({ reportId }: Props) {
const [isPending, startTransition] = useTransition();
const handleAction = (status: 'RESOLVED' | 'DISMISSED') => {
startTransition(async () => {
const result = await updateReportStatus(reportId, status);
if (result.success) {
toast.success(status === 'RESOLVED' ? "Signalement résolu" : "Signalement rejeté");
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="flex gap-2 justify-end">
<button
onClick={() => handleAction('RESOLVED')}
disabled={isPending}
className="btn-verify px-3 py-1.5 text-xs"
title="Marquer comme résolu"
>
<div className="flex items-center gap-1.5">
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
Résoudre
</div>
</button>
<button
onClick={() => handleAction('DISMISSED')}
disabled={isPending}
className="btn-unverify px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 border-none"
title="Rejeter le signalement"
>
<div className="flex items-center gap-1.5">
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
Rejeter
</div>
</button>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import React, { useMemo, useRef } from 'react';
import dynamic from 'next/dynamic';
import 'react-quill-new/dist/quill.snow.css';
// We need to import Quill for registration
const ReactQuill = dynamic(async () => {
const { default: RQ, Quill } = await import("react-quill-new");
// Register custom Divider Blot
if (Quill) {
const BlockEmbed = Quill.import('blots/block/embed') as any;
class DividerBlot extends BlockEmbed {
static blotName = 'divider';
static tagName = 'hr';
}
Quill.register(DividerBlot);
}
return RQ;
}, {
ssr: false,
loading: () => <div className="h-64 w-full bg-slate-900 animate-pulse rounded-lg border border-slate-700" />
});
interface RichTextEditorProps {
value: string;
onChange: (content: string) => void;
placeholder?: string;
}
export default function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
const quillRef = useRef<any>(null);
const modules = useMemo(() => ({
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['divider', 'link', 'clean'],
],
handlers: {
divider: function() {
const quill = (quillRef.current as any)?.getEditor();
if (quill) {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, 'divider', true);
quill.setSelection(range.index + 1);
}
}
}
}
}), []);
const formats = [
'header',
'bold', 'italic', 'underline', 'strike',
'list', 'bullet',
'divider', 'link',
];
return (
<div className="quill-dark-wrapper">
<ReactQuill
ref={quillRef}
theme="snow"
value={value}
onChange={onChange}
modules={modules}
formats={formats}
placeholder={placeholder}
className="bg-slate-900 text-white rounded-lg overflow-hidden border border-slate-700 min-h-[300px]"
/>
</div>
);
}

View File

@@ -9,14 +9,17 @@ import {
BookOpen,
LogOut,
ShieldCheck,
BarChart3
BarChart3,
Flag
} from 'lucide-react';
const menuItems = [
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
{ name: 'Clients', icon: Users, href: '/clients' },
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
];

View File

@@ -0,0 +1,111 @@
"use client";
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ShieldAlert, X, AlertOctagon } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface SuspensionModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (reason: string) => Promise<void>;
title: string;
subtitle: string;
}
export default function SuspensionModal({
isOpen,
onClose,
onConfirm,
title,
subtitle
}: SuspensionModalProps) {
const [reason, setReason] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!isOpen || !mounted) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!reason.trim()) {
toast.error("Veuillez saisir un motif de suspension");
return;
}
setIsSubmitting(true);
try {
await onConfirm(reason);
setReason('');
onClose();
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const modalContent = (
<div className="modal-overlay" style={{ zIndex: 9999 }}>
<div className="modal-content max-w-md shadow-2xl">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3 text-red-500">
<ShieldAlert className="w-7 h-7" />
<h2 className="text-2xl font-bold text-white">{title}</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-slate-800 text-slate-500 hover:text-white transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-red-500/10 border border-red-500/20 p-4 rounded-xl flex gap-3 mb-6">
<AlertOctagon className="w-6 h-6 text-red-500 shrink-0" />
<p className="text-sm text-slate-300 leading-relaxed font-medium">{subtitle}</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest">
Motif de la suspension
</label>
<textarea
required
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Expliquez pourquoi ce compte est suspendu. Ce message sera visible par l'utilisateur."
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-500 transition-all text-sm shadow-inner"
/>
</div>
<div className="flex gap-3 pt-6">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition-all font-bold text-sm"
>
Annuler
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-xl transition-all font-bold text-sm shadow-lg shadow-red-900/40"
>
{isSubmitting ? "En cours..." : "Confirmer"}
</button>
</div>
</form>
</div>
</div>
);
return createPortal(modalContent, document.body);
}