'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 (
);
}