'use client'; import { useCallback, useRef } from 'react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Image from '@tiptap/extension-image'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import Underline from '@tiptap/extension-underline'; import TextAlign from '@tiptap/extension-text-align'; import Color from '@tiptap/extension-color'; import { TextStyle } from '@tiptap/extension-text-style'; import Highlight from '@tiptap/extension-highlight'; import { Bold, Italic, UnderlineIcon, Strikethrough, Code, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Minus, Link as LinkIcon, ImageIcon, AlignLeft, AlignCenter, AlignRight, Highlighter, Undo, Redo, RemoveFormatting, Loader2, } from 'lucide-react'; import { upload } from '@/lib/api/client'; import { useState } from 'react'; import { useToast } from '@/components/ui/toast'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; interface RichTextEditorProps { content: string; onChange: (html: string) => void; placeholder?: string; minHeight?: number; } interface ToolbarButtonProps { onClick: () => void; active?: boolean; disabled?: boolean; title: string; children: React.ReactNode; } function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarButtonProps) { return ( ); } function Separator() { return
; } function Toolbar({ editor, onImageUpload, uploading, }: { editor: Editor; onImageUpload: () => void; uploading: boolean; }) { const setLink = useCallback(() => { const previousUrl = editor.getAttributes('link').href; const url = window.prompt('URL du lien :', previousUrl); if (url === null) return; if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } editor.chain().focus().extendMarkRange('link').setLink({ href: url, target: '_blank' }).run(); }, [editor]); return (
{/* Undo / Redo */} editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Annuler" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Rétablir" > {/* Headings */} editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Titre 1" > editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Titre 2" > editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Titre 3" > {/* Inline formatting */} editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Gras" > editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italique" > editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Souligné" > editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Barré" > editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Code inline" > editor.chain().focus().toggleHighlight().run()} active={editor.isActive('highlight')} title="Surligner" > {/* Alignment */} editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} title="Aligner à gauche" > editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} title="Centrer" > editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} title="Aligner à droite" > {/* Lists */} editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Liste à puces" > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Liste numérotée" > editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Citation" > editor.chain().focus().setHorizontalRule().run()} title="Séparateur" > {/* Link & Image */} {/* Clear formatting */} editor.chain().focus().unsetAllMarks().clearNodes().run()} title="Effacer le formatage" > {/* Text color */}
editor.chain().focus().setColor(e.target.value).run()} title="Couleur du texte" />
); } function normalizeContentUrls(html: string): string { return html.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`); } export function RichTextEditor({ content, onChange, placeholder = 'Écrivez votre article...', minHeight = 400, }: RichTextEditorProps) { const { toast } = useToast(); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), Image.configure({ inline: false, allowBase64: false }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-brand-blue underline' }, }), Placeholder.configure({ placeholder }), Underline, TextAlign.configure({ types: ['heading', 'paragraph'] }), TextStyle, Color, Highlight.configure({ multicolor: false }), ], content: normalizeContentUrls(content), onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, editorProps: { attributes: { class: 'prose prose-lg max-w-none focus:outline-none px-6 py-4', style: `min-height: ${minHeight}px`, }, }, }); const handleImageUpload = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileChange = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !editor) return; const MAX_SIZE = 5 * 1024 * 1024; if (file.size > MAX_SIZE) { toast.error('Image trop volumineuse (max 5 Mo)'); return; } setUploading(true); try { const formData = new FormData(); formData.append('image', file); const result = await upload<{ url: string; filename: string }>( '/api/v1/admin/blog/images', formData ); const src = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`; editor.chain().focus().setImage({ src, alt: file.name }).run(); } catch (err: any) { toast.error(err.message || "Erreur lors de l'upload de l'image"); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }, [editor] ); if (!editor) return null; return (
); }