import Image from '@tiptap/extension-image'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import TextAlign from '@tiptap/extension-text-align'; import Underline from '@tiptap/extension-underline'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { AlignCenter, AlignLeft, AlignRight, Bold, Heading2, Heading3, ImagePlus, Italic, Link as LinkIcon, List, ListOrdered, Loader2, Redo2, Underline as UnderlineIcon, Undo2, X, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { richTextContentClass } from '@/lib/rich-text-content'; import { cn } from '@/lib/utils'; import { store as uploadMedia } from '@/routes/media/uploads'; type MediaItem = { id: number; name: string; file_name: string; mime_type: string; size: number; url: string; }; type TiptapEditorProps = { value?: string; onChange?: (value: string) => void; name?: string; placeholder?: string; collection?: string; error?: string; }; function ToolbarButton({ onClick, isActive = false, disabled = false, children, title, }: { onClick: () => void; isActive?: boolean; disabled?: boolean; children: React.ReactNode; title?: string; }) { return ( ); } function ToolbarDivider() { return
; } export default function TiptapEditor({ value = '', onChange, name = 'content', placeholder = 'Tulis konten di sini...', collection = 'content', error, }: TiptapEditorProps) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(''); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [2, 3], }, }), Image.configure({ inline: false, allowBase64: true, }), Placeholder.configure({ placeholder, }), Underline, TextAlign.configure({ types: ['heading', 'paragraph'], }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-primary underline cursor-pointer', }, }), ], content: value, onUpdate: ({ editor: e }) => { onChange?.(e.getHTML()); }, editorProps: { attributes: { class: cn( 'prose prose-sm sm:prose-base max-w-none', 'min-h-[300px] w-full rounded-b-md px-3 py-2', 'focus:outline-none', richTextContentClass, 'placeholder:text-muted-foreground', ), }, }, }); useEffect(() => { if (editor && value !== editor.getHTML()) { editor.commands.setContent(value); } }, [value, editor]); const getCsrfToken = () => document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content') ?? ''; const handleImageUpload = useCallback( async (file: File) => { if (!editor) { return; } setUploading(true); try { const formData = new FormData(); formData.append('file', file); formData.append('collection', collection); const res = await fetch(uploadMedia.url(), { method: 'POST', headers: { 'X-CSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, body: formData, }); if (!res.ok) { const body = await res.json().catch(() => null); const message = body?.errors?.file?.[0] ?? body?.message ?? 'Gagal mengunggah gambar.'; throw new Error(message); } const media: MediaItem = await res.json(); editor .chain() .focus() .setImage({ src: media.url, alt: file.name }) .run(); } catch (err) { const message = err instanceof Error ? err.message : 'Gagal mengunggah gambar.'; toast.error(message); console.error('Image upload error:', err); } finally { setUploading(false); } }, [editor, collection], ); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { handleImageUpload(file); } if (fileInputRef.current) { fileInputRef.current.value = ''; } }, [handleImageUpload], ); const handleSetLink = useCallback(() => { if (!editor) { return; } if (linkUrl) { editor .chain() .focus() .extendMarkRange('link') .setLink({ href: linkUrl }) .run(); } else { editor.chain().focus().extendMarkRange('link').unsetLink().run(); } setShowLinkInput(false); setLinkUrl(''); }, [editor, linkUrl]); if (!editor) { return null; } return (
editor.chain().focus().toggleBold().run() } isActive={editor.isActive('bold')} title="Bold" > editor.chain().focus().toggleItalic().run() } isActive={editor.isActive('italic')} title="Italic" > editor.chain().focus().toggleUnderline().run() } isActive={editor.isActive('underline')} title="Underline" > editor .chain() .focus() .toggleHeading({ level: 2 }) .run() } isActive={editor.isActive('heading', { level: 2 })} title="Heading 2" > editor .chain() .focus() .toggleHeading({ level: 3 }) .run() } isActive={editor.isActive('heading', { level: 3 })} title="Heading 3" > editor.chain().focus().toggleBulletList().run() } isActive={editor.isActive('bulletList')} title="Bullet List" > editor.chain().focus().toggleOrderedList().run() } isActive={editor.isActive('orderedList')} title="Ordered List" > editor.chain().focus().setTextAlign('left').run() } isActive={editor.isActive({ textAlign: 'left' })} title="Align Left" > editor.chain().focus().setTextAlign('center').run() } isActive={editor.isActive({ textAlign: 'center' })} title="Align Center" > editor.chain().focus().setTextAlign('right').run() } isActive={editor.isActive({ textAlign: 'right' })} title="Align Right" > { if (editor.isActive('link')) { editor.chain().focus().unsetLink().run(); } else { setShowLinkInput(true); } }} isActive={editor.isActive('link')} title="Insert Link" > fileInputRef.current?.click()} disabled={uploading} title="Insert Image" > {uploading ? ( ) : ( )}
editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo" >
{showLinkInput && (
setLinkUrl(e.target.value)} placeholder="https://example.com" className="flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSetLink(); } if (e.key === 'Escape') { setShowLinkInput(false); setLinkUrl(''); } }} autoFocus />
)}
{error &&

{error}

}
); }