import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; import Placeholder from '@tiptap/extension-placeholder'; import { Node } from '@tiptap/core'; import { Button } from '@/components/ui/button'; import { Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, Image as ImageIcon, Heading1, Heading2, Undo, Redo, Maximize2, Minimize2, MousePointer, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useCallback, useEffect, useState } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from '@/hooks/use-toast'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; interface RichTextEditorProps { content: string; onChange: (html: string) => void; placeholder?: string; className?: string; } // Custom Image extension with resize support const ResizableImage = Image.extend({ addAttributes() { return { ...this.parent?.(), width: { default: null, parseHTML: element => element.getAttribute('width') || element.style.width?.replace('px', ''), renderHTML: attributes => { if (!attributes.width) return {}; return { width: attributes.width, style: `width: ${attributes.width}px` }; }, }, height: { default: null, parseHTML: element => element.getAttribute('height') || element.style.height?.replace('px', ''), renderHTML: attributes => { if (!attributes.height) return {}; return { height: attributes.height, style: `height: ${attributes.height}px` }; }, }, }; }, }); // Custom Button extension for email templates const EmailButton = Node.create({ name: 'emailButton', group: 'block', addAttributes() { return { url: { default: '#', parseHTML: element => element.getAttribute('data-url') || '#', renderHTML: attributes => ({ 'data-url': attributes.url, }), }, text: { default: 'Button', parseHTML: element => element.textContent || 'Button', renderHTML: attributes => ({}), }, fullWidth: { default: false, parseHTML: element => element.classList.contains('btn-full'), renderHTML: attributes => ({ class: attributes.fullWidth ? 'btn btn-full' : 'btn', }), }, }; }, parseHTML() { return [ { tag: 'div[data-email-button]', }, ]; }, renderHTML({ HTMLAttributes, node }) { const { url, text, fullWidth } = node.attrs; return [ 'p', { style: 'margin-top: 20px; text-align: center;' }, [ 'a', { href: url, class: fullWidth ? 'btn btn-full' : 'btn', 'data-email-button': '', style: ` display: inline-block; background-color: #000; color: #FFF !important; padding: 14px 28px; font-weight: 700; text-transform: uppercase; text-decoration: none !important; font-size: 16px; border: 2px solid #000; box-shadow: 4px 4px 0px 0px #000000; margin: 10px 0; transition: all 0.1s; text-align: center; ${fullWidth ? 'width: 100%; box-sizing: border-box;' : ''} `, }, text || 'Button', ], ]; }, addNodeView() { return ({ node, editor }) => { const dom = document.createElement('div'); dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;'; const button = document.createElement('a'); button.href = node.attrs.url; button.textContent = node.attrs.text; button.style.cssText = ` display: inline-block; background-color: #000; color: #FFF; padding: 14px 28px; font-weight: 700; text-transform: uppercase; text-decoration: none; font-size: 16px; border: 2px solid #000; box-shadow: 4px 4px 0px 0px #000000; cursor: pointer; ${node.attrs.fullWidth ? 'width: 100%; text-align: center; box-sizing: border-box;' : ''} `; dom.appendChild(button); return { dom, destroy: () => { dom.remove(); }, }; }; }, }); // Custom OTP Box extension const OTPBox = Node.create({ name: 'otpBox', group: 'block', addAttributes() { return { code: { default: '123-456', parseHTML: element => element.getAttribute('data-code') || '123-456', renderHTML: attributes => ({ 'data-code': attributes.code, }), }, }; }, parseHTML() { return [ { tag: 'div[data-otp-box]', }, ]; }, renderHTML({ HTMLAttributes, node }) { const { code } = node.attrs; return [ 'div', { 'data-otp-box': '', style: ` background-color: #F4F4F5; border: 2px dashed #000; padding: 20px; text-align: center; margin: 20px 0; letter-spacing: 5px; font-family: 'Courier New', Courier, monospace; font-size: 32px; font-weight: 700; color: #000; `, }, code, ]; }, addNodeView() { return ({ node, editor }) => { const dom = document.createElement('div'); dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;'; dom.innerHTML = `
OTP Box: ${node.attrs.code}
${node.attrs.code}
`; return { dom, destroy: () => { dom.remove(); }, }; }; }, }); export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) { const [uploading, setUploading] = useState(false); const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null); const [imageWidth, setImageWidth] = useState(''); const [imageHeight, setImageHeight] = useState(''); const editor = useEditor({ extensions: [ StarterKit, Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-primary underline', }, }), ResizableImage.configure({ HTMLAttributes: { class: 'max-w-full h-auto rounded-md cursor-pointer', }, }), EmailButton, OTPBox, Placeholder.configure({ placeholder, }), ], content, onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, onSelectionUpdate: ({ editor }) => { // Check if an image is selected const { selection } = editor.state; const node = editor.state.doc.nodeAt(selection.from); if (node?.type.name === 'image') { setSelectedImage({ src: node.attrs.src, width: node.attrs.width, height: node.attrs.height, }); setImageWidth(node.attrs.width?.toString() || ''); setImageHeight(node.attrs.height?.toString() || ''); } else { setSelectedImage(null); } }, }); // Sync content when it changes externally useEffect(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content || ''); } }, [content, editor]); const addLink = useCallback(() => { if (!editor) return; const url = window.prompt('Masukkan URL:'); if (url) { editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); } }, [editor]); const addButton = useCallback(() => { if (!editor) return; const text = window.prompt('Teks Button:') || 'Button'; const url = window.prompt('URL Button:') || '#'; const fullWidth = window.confirm('Gunakan lebar penuh?'); editor.chain().focus().insertContent({ type: 'emailButton', attrs: { text, url, fullWidth, }, }).run(); }, [editor]); const addOTPBox = useCallback(() => { if (!editor) return; const code = window.prompt('Kode OTP (contoh: 123-456):') || '123-456'; editor.chain().focus().insertContent({ type: 'otpBox', attrs: { code, }, }).run(); }, [editor]); const uploadImageToStorage = async (file: File): Promise => { try { const fileExt = file.name.split('.').pop(); const fileName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${fileExt}`; const filePath = `editor-images/${fileName}`; const { data, error } = await supabase.storage .from('content') .upload(filePath, file, { cacheControl: '3600', upsert: false, }); if (error) { console.error('Storage upload error:', error); // Fall back to base64 if storage fails return null; } const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath); return urlData.publicUrl; } catch (err) { console.error('Upload error:', err); return null; } }; const convertToDataUrl = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); }; const handleImageUpload = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !editor) return; setUploading(true); // Try to upload to storage first let imageUrl = await uploadImageToStorage(file); // Fall back to base64 if storage upload fails if (!imageUrl) { toast({ title: 'Info', description: 'Menggunakan penyimpanan lokal untuk gambar', }); imageUrl = await convertToDataUrl(file); } editor.chain().focus().setImage({ src: imageUrl }).run(); setUploading(false); // Reset input e.target.value = ''; }, [editor]); const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items || !editor) return; for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { e.preventDefault(); const file = item.getAsFile(); if (!file) continue; setUploading(true); let imageUrl = await uploadImageToStorage(file); if (!imageUrl) { imageUrl = await convertToDataUrl(file); } editor.chain().focus().setImage({ src: imageUrl }).run(); setUploading(false); break; } } }, [editor]); const updateImageSize = useCallback(() => { if (!editor || !selectedImage) return; const width = imageWidth ? parseInt(imageWidth) : null; const height = imageHeight ? parseInt(imageHeight) : null; editor.chain().focus().updateAttributes('image', { width, height }).run(); toast({ title: 'Berhasil', description: 'Ukuran gambar diperbarui' }); }, [editor, selectedImage, imageWidth, imageHeight]); const setPresetSize = useCallback((size: 'small' | 'medium' | 'large' | 'full') => { if (!editor) return; const sizes = { small: { width: 200, height: null }, medium: { width: 400, height: null }, large: { width: 600, height: null }, full: { width: null, height: null }, }; const { width, height } = sizes[size]; editor.chain().focus().updateAttributes('image', { width, height }).run(); setImageWidth(width?.toString() || ''); setImageHeight(height?.toString() || ''); }, [editor]); if (!editor) return null; return (
{/* Email Components Separator */}
{/* Email Component Buttons */} {/* Image Upload Separator */}
{/* Image resize popover */} {selectedImage && (

Ukuran Gambar

setImageWidth(e.target.value)} placeholder="Auto" className="h-8" />
setImageHeight(e.target.value)} placeholder="Auto" className="h-8" />
)}
{uploading && (
Mengunggah gambar...
)}
); }