xpeditis2.0/apps/frontend/src/components/blog/RichTextEditor.tsx
2026-08-13 17:43:11 +02:00

384 lines
11 KiB
TypeScript

'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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-1.5 rounded transition-colors ${
active ? 'bg-brand-navy text-white' : 'text-neutral-600 hover:bg-neutral-100 hover:text-brand-navy'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>
{children}
</button>
);
}
function Separator() {
return <div className="w-px h-5 bg-neutral-200 mx-1" />;
}
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 (
<div className="flex flex-wrap items-center gap-0.5 p-2 border-b border-border bg-neutral-50 rounded-t-lg">
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annuler"
>
<Undo className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Rétablir"
>
<Redo className="w-4 h-4" />
</ToolbarButton>
<Separator />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Titre 1"
>
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Titre 2"
>
<Heading2 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Titre 3"
>
<Heading3 className="w-4 h-4" />
</ToolbarButton>
<Separator />
{/* Inline formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Gras"
>
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italique"
>
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive('underline')}
title="Souligné"
>
<UnderlineIcon className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
title="Barré"
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Code inline"
>
<Code className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHighlight().run()}
active={editor.isActive('highlight')}
title="Surligner"
>
<Highlighter className="w-4 h-4" />
</ToolbarButton>
<Separator />
{/* Alignment */}
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title="Aligner à gauche"
>
<AlignLeft className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title="Centrer"
>
<AlignCenter className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title="Aligner à droite"
>
<AlignRight className="w-4 h-4" />
</ToolbarButton>
<Separator />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Liste à puces"
>
<List className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Liste numérotée"
>
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Citation"
>
<Quote className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Séparateur"
>
<Minus className="w-4 h-4" />
</ToolbarButton>
<Separator />
{/* Link & Image */}
<ToolbarButton onClick={setLink} active={editor.isActive('link')} title="Insérer un lien">
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
<button
type="button"
onClick={onImageUpload}
disabled={uploading}
title="Insérer une image"
className="p-1.5 rounded text-neutral-600 hover:bg-neutral-100 hover:text-brand-navy transition-colors disabled:opacity-40"
>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ImageIcon className="w-4 h-4" />
)}
</button>
<Separator />
{/* Clear formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
title="Effacer le formatage"
>
<RemoveFormatting className="w-4 h-4" />
</ToolbarButton>
{/* Text color */}
<div className="relative" title="Couleur du texte">
<input
type="color"
className="w-6 h-6 rounded cursor-pointer border border-neutral-300 p-0.5"
value={editor.getAttributes('textStyle').color || '#000000'}
onChange={e => editor.chain().focus().setColor(e.target.value).run()}
title="Couleur du texte"
/>
</div>
</div>
);
}
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="border border-neutral-300 rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-ring focus-within:border-transparent">
<Toolbar editor={editor} onImageUpload={handleImageUpload} uploading={uploading} />
<EditorContent editor={editor} />
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}