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:
@@ -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>
|
||||
|
||||
67
admin/src/components/ClientActions.tsx
Normal file
67
admin/src/components/ClientActions.tsx
Normal 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.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
admin/src/components/EntrepreneurActions.tsx
Normal file
98
admin/src/components/EntrepreneurActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
62
admin/src/components/ModerationSuspensionButton.tsx
Normal file
62
admin/src/components/ModerationSuspensionButton.tsx
Normal 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 ?`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
admin/src/components/ReportActionButtons.tsx
Normal file
53
admin/src/components/ReportActionButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
admin/src/components/RichTextEditor.tsx
Normal file
78
admin/src/components/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
111
admin/src/components/SuspensionModal.tsx
Normal file
111
admin/src/components/SuspensionModal.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user