384 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|