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 { cn } from '@/lib/utils'; 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; modelType?: string; modelId?: number | 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...', modelType = 'news', modelId = 0, collection = 'content', error, }: TiptapEditorProps) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(''); const [draftId, setDraftId] = useState(null); 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', '[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold', '[&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold', '[&_p]:mb-2', '[&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-6', '[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6', '[&_li]:mb-1', '[&_img]:my-4 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md', '[&_a]:text-primary [&_a]:underline', '[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic', '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 token = getCsrfToken(); const headers = { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token, Accept: 'application/json', }; let currentModelId = draftId ?? (modelId ? Number(modelId) : null); if (!currentModelId) { const initRes = await fetch(`/media/${modelType}`, { method: 'POST', headers, }); if (!initRes.ok) { throw new Error('Gagal membuat draft.'); } const { id } = await initRes.json(); currentModelId = id; setDraftId(id); } const presignedRes = await fetch( `/media/${modelType}/${currentModelId}/presigned-url`, { method: 'POST', headers, body: JSON.stringify({ file_name: file.name, mime_type: file.type, size: file.size, collection, }), }, ); if (!presignedRes.ok) { throw new Error('Gagal mendapatkan URL unggahan.'); } const { id, presigned_url, headers: putHeaders, } = await presignedRes.json(); await fetch(presigned_url, { method: 'PUT', headers: putHeaders, body: file, }); const completeRes = await fetch(`/media/item/${id}/complete`, { method: 'POST', headers: { 'X-CSRF-TOKEN': token, Accept: 'application/json', }, }); if (!completeRes.ok) { throw new Error('Gagal menyelesaikan unggahan.'); } const media: MediaItem = await completeRes.json(); editor .chain() .focus() .setImage({ src: media.url, alt: file.name }) .run(); } catch (err) { console.error('Image upload error:', err); } finally { setUploading(false); } }, [editor, modelType, modelId, collection, draftId], ); 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}

}
); }