- Removed custom Tiptap table extensions that were causing import errors - Kept EmailButton and OTPBox components working - Table functionality will need proper Tiptap table extension setup later - Build now completes successfully for deployment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
642 lines
19 KiB
TypeScript
642 lines
19 KiB
TypeScript
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 = `
|
|
<div style="text-align: center; font-size: 12px; color: #007acc; margin-bottom: 4px;">OTP Box: ${node.attrs.code}</div>
|
|
<div style="
|
|
background-color: #F4F4F5;
|
|
border: 2px dashed #000;
|
|
padding: 20px;
|
|
letter-spacing: 5px;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: #000;
|
|
text-align: center;
|
|
">${node.attrs.code}</div>
|
|
`;
|
|
|
|
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<string | null> => {
|
|
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<string> => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className={cn("border-2 border-border rounded-md", className)}>
|
|
<div className="flex flex-wrap gap-1 p-2 border-b-2 border-border bg-muted">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
className={editor.isActive('heading', { level: 1 }) ? 'bg-accent' : ''}
|
|
>
|
|
<Heading1 className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
className={editor.isActive('heading', { level: 2 }) ? 'bg-accent' : ''}
|
|
>
|
|
<Heading2 className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
|
|
>
|
|
<ListOrdered className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
className={editor.isActive('blockquote') ? 'bg-accent' : ''}
|
|
>
|
|
<Quote className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addLink}
|
|
className={editor.isActive('link') ? 'bg-accent' : ''}
|
|
>
|
|
<LinkIcon className="w-4 h-4" />
|
|
</Button>
|
|
|
|
{/* Email Components Separator */}
|
|
<div className="w-px h-6 bg-border mx-1" />
|
|
|
|
{/* Email Component Buttons */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addButton}
|
|
title="Tambah Email Button"
|
|
>
|
|
<MousePointer className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addOTPBox}
|
|
title="Tambah OTP Box"
|
|
>
|
|
<Square className="w-4 h-4" />
|
|
</Button>
|
|
|
|
{/* Image Upload Separator */}
|
|
<div className="w-px h-6 bg-border mx-1" />
|
|
|
|
<label>
|
|
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
|
|
<span className={uploading ? 'opacity-50' : ''}>
|
|
<ImageIcon className="w-4 h-4" />
|
|
</span>
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageUpload}
|
|
className="hidden"
|
|
disabled={uploading}
|
|
/>
|
|
</label>
|
|
|
|
{/* Image resize popover */}
|
|
{selectedImage && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button type="button" variant="ghost" size="sm" className="bg-accent">
|
|
<Maximize2 className="w-4 h-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64">
|
|
<div className="space-y-4">
|
|
<h4 className="font-medium">Ukuran Gambar</h4>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('small')}>S</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('medium')}>M</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('large')}>L</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('full')}>Full</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">Lebar (px)</Label>
|
|
<Input
|
|
type="number"
|
|
value={imageWidth}
|
|
onChange={(e) => setImageWidth(e.target.value)}
|
|
placeholder="Auto"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Tinggi (px)</Label>
|
|
<Input
|
|
type="number"
|
|
value={imageHeight}
|
|
onChange={(e) => setImageHeight(e.target.value)}
|
|
placeholder="Auto"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button type="button" size="sm" onClick={updateImageSize} className="w-full">
|
|
Terapkan
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
>
|
|
<Undo className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
>
|
|
<Redo className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div onPaste={handlePaste}>
|
|
<EditorContent
|
|
editor={editor}
|
|
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary"
|
|
/>
|
|
</div>
|
|
{uploading && (
|
|
<div className="p-2 text-sm text-muted-foreground text-center border-t border-border">
|
|
Mengunggah gambar...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|