almeira-web/src/components/dashboard/RichTextEditor.tsx

331 lines
11 KiB
TypeScript

'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import { Node, mergeAttributes } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import TextAlign from '@tiptap/extension-text-align';
import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import Youtube from '@tiptap/extension-youtube';
import {
Bold, Italic, Underline as UnderlineIcon,
Link as LinkIcon, AlignLeft, AlignCenter,
AlignRight, AlignJustify, Highlighter, Type,
List, ListOrdered, Quote, Undo, Redo,
ImageIcon, Video, Youtube as YoutubeIcon, Palette
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { MediaGridSelector } from './MediaGridSelector';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
label: string;
}
const VideoNode = Node.create({
name: 'videoNode',
group: 'block',
selectable: true,
draggable: true,
atom: true,
addAttributes() {
return {
src: { default: null },
controls: { default: 'true' },
}
},
parseHTML() {
return [{ tag: 'video' }]
},
renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(HTMLAttributes, { class: 'w-full aspect-video rounded-xl shadow-lg my-6', controls: 'true', preload: 'metadata' })]
},
});
const MenuBar = ({ editor }: { editor: any }) => {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
if (!editor) return null;
const setLink = () => {
const url = window.prompt('URL del enlace:');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
};
const addYoutubeVideo = () => {
const url = window.prompt('URL de YouTube:');
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 640,
height: 480,
});
}
};
const handleMediaInsert = (fileId: string, url?: string, tipo?: string) => {
if (url) {
if (tipo === 'video') {
editor.chain().focus().insertContent(`<video src="${url}" controls="true" preload="metadata" class="w-full aspect-video rounded-xl shadow-lg my-6"></video>`).run();
} else {
editor.chain().focus().setImage({ src: url }).run();
}
}
setIsMediaModalOpen(false);
};
const setColor = (e: React.ChangeEvent<HTMLInputElement>) => {
editor.chain().focus().setColor(e.target.value).run();
};
return (
<>
<div className="flex flex-wrap gap-1 p-2 bg-slate-900 border-b border-slate-800 sticky top-0 z-10 items-center">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bold') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
title="Negrita"
>
<Bold size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('italic') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
title="Cursiva"
>
<Italic size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('underline') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
title="Subrayado"
>
<UnderlineIcon size={18} />
</button>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'left' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<AlignLeft size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'center' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<AlignCenter size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'right' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<AlignRight size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'justify' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<AlignJustify size={18} />
</button>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bulletList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<List size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('orderedList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<ListOrdered size={18} />
</button>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
<button
type="button"
onClick={setLink}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('link') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<LinkIcon size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHighlight().run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('highlight') ? 'text-yellow-400 bg-slate-800' : 'text-slate-400'}`}
>
<Highlighter size={18} />
</button>
<div className="relative group flex items-center">
<label htmlFor="colorPicker" className="p-2 cursor-pointer text-slate-400 hover:bg-slate-800 rounded transition-colors group-hover:text-cyan-400" title="Color del texto">
<Palette size={18} />
</label>
<input
id="colorPicker"
type="color"
onChange={setColor}
value={editor.getAttributes('textStyle').color || '#ffffff'}
className="absolute opacity-0 w-0 h-0"
/>
</div>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
<button
type="button"
onClick={() => setIsMediaModalOpen(true)}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Insertar Imagen de Biblioteca"
>
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={() => setIsMediaModalOpen(true)}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Insertar Video de Biblioteca"
>
<Video size={18} />
</button>
<button
type="button"
onClick={addYoutubeVideo}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-red-400 transition-colors"
title="Insertar Video YouTube"
>
<YoutubeIcon size={18} />
</button>
<div className="flex-grow" />
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
className="p-2 text-slate-500 hover:text-white transition-colors"
>
<Undo size={18} />
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
className="p-2 text-slate-500 hover:text-white transition-colors"
>
<Redo size={18} />
</button>
</div>
<MediaGridSelector
mode="modal"
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onChange={handleMediaInsert}
/>
</>
);
};
export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) => {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-cyan-400 underline decoration-cyan-400/50 hover:text-cyan-300 transition-colors',
},
}),
TextStyle,
Color,
Image.configure({
inline: true,
allowBase64: true,
HTMLAttributes: {
class: 'rounded-xl shadow-lg max-w-full h-auto object-cover my-6'
}
}),
VideoNode,
Youtube.configure({
controls: true,
HTMLAttributes: {
class: 'w-full aspect-video rounded-xl shadow-lg my-6'
}
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
}),
Highlight.configure({ multicolor: true }),
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none focus:outline-none min-h-[300px] p-6 text-slate-200 leading-relaxed custom-scrollbar whitespace-pre-wrap break-words prose-p:empty:before:content-["\\00a0"]',
},
},
});
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Keep content synced if value changes externally
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [value, editor]);
if (!mounted) {
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
<div className="h-[400px] bg-slate-900 animate-pulse rounded-2xl border border-slate-800" />
</div>
);
}
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
<div className="bg-slate-950 border border-slate-800 rounded-2xl overflow-hidden focus-within:border-cyan-500/50 transition-all shadow-xl flex flex-col max-h-[600px]">
<MenuBar editor={editor} />
<div className="overflow-y-auto custom-scrollbar flex-1">
<EditorContent editor={editor} />
</div>
</div>
{/* Hidden input for form submission if needed, though we use controlled value */}
<input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} />
</div>
);
};