Files
Spanglish/frontend/src/components/ui/RichTextEditor.tsx
Michilis 07ba357194 feat: FAQ management from admin, public /faq, homepage section, llms.txt
- Backend: faq_questions table (schema + migration), CRUD + reorder API, Swagger docs
- Admin: FAQ page with create/edit, enable/disable, show on homepage, drag reorder
- Public /faq page fetches enabled FAQs from API; layout builds dynamic JSON-LD
- Homepage: FAQ section under Stay updated (homepage-enabled only) with See full FAQ link
- llms.txt: FAQ section uses homepage FAQs from API
- i18n: home.faq title/seeFull, admin FAQ nav

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 04:49:16 +00:00

420 lines
13 KiB
TypeScript

'use client';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
interface RichTextEditorProps {
content: string; // Markdown content
onChange: (content: string) => void; // Returns markdown
placeholder?: string;
className?: string;
editable?: boolean;
}
// Convert markdown to HTML for TipTap
function markdownToHtml(markdown: string): string {
if (!markdown) return '<p></p>';
let html = markdown;
// Convert horizontal rules first (before other processing)
html = html.replace(/^---+$/gm, '<hr>');
// Convert headings (must be done before other inline formatting)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Convert bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Convert unordered lists
const lines = html.split('\n');
let inList = false;
let listType = '';
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/);
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ul>');
inList = true;
listType = 'ul';
}
processedLines.push(`<li>${bulletMatch[1]}</li>`);
} else if (numberedMatch) {
if (!inList || listType !== 'ol') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ol>');
inList = true;
listType = 'ol';
}
processedLines.push(`<li>${numberedMatch[1]}</li>`);
} else {
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
processedLines.push(line);
}
}
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
}
html = processedLines.join('\n');
// Convert blockquotes
html = html.replace(/^>\s*(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
// Convert paragraphs (lines that aren't already HTML tags)
const finalLines = html.split('\n');
const result: string[] = [];
let paragraph: string[] = [];
for (const line of finalLines) {
const trimmed = line.trim();
if (!trimmed) {
// Empty line - close paragraph if open
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
} else if (trimmed.startsWith('<h') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') ||
trimmed.startsWith('<li') || trimmed.startsWith('</ul') || trimmed.startsWith('</ol') ||
trimmed.startsWith('<hr') || trimmed.startsWith('<blockquote')) {
// HTML tag - close paragraph and add tag
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
result.push(trimmed);
} else {
// Regular text - add to paragraph
paragraph.push(trimmed);
}
}
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
}
return result.join('') || '<p></p>';
}
// Convert HTML from TipTap back to markdown
function htmlToMarkdown(html: string): string {
if (!html) return '';
let md = html;
// Convert headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert bold and italic
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert lists
md = md.replace(/<ul[^>]*>/gi, '\n');
md = md.replace(/<\/ul>/gi, '\n');
md = md.replace(/<ol[^>]*>/gi, '\n');
md = md.replace(/<\/ol>/gi, '\n');
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n');
// Convert blockquotes
md = md.replace(/<blockquote[^>]*><p[^>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n');
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n');
// Convert horizontal rules
md = md.replace(/<hr[^>]*\/?>/gi, '\n---\n\n');
// Convert paragraphs
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
md = md.replace(/<br[^>]*\/?>/gi, '\n');
// Remove any remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Decode HTML entities
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/&nbsp;/g, ' ');
// Clean up extra newlines
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}
// Toolbar button component
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={clsx(
'p-2 rounded transition-colors',
isActive
? 'bg-primary-yellow text-primary-dark'
: 'text-gray-600 hover:bg-gray-100',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{children}
</button>
);
}
// Toolbar component
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return null;
return (
<div className="flex flex-wrap gap-1 p-2 border-b border-gray-200 bg-gray-50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 4h4m-2 0v16m-4 0h8" transform="skewX(-10)" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<span className="text-sm font-bold">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<span className="text-sm font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<span className="text-sm font-bold">H3</span>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
<circle cx="2" cy="6" r="1" fill="currentColor" />
<circle cx="2" cy="12" r="1" fill="currentColor" />
<circle cx="2" cy="18" r="1" fill="currentColor" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Numbered List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h13M7 12h13M7 18h13" />
<text x="1" y="8" fontSize="6" fill="currentColor">1</text>
<text x="1" y="14" fontSize="6" fill="currentColor">2</text>
<text x="1" y="20" fontSize="6" fill="currentColor">3</text>
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Block elements */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Quote"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Undo/Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</ToolbarButton>
</div>
);
}
export default function RichTextEditor({
content,
onChange,
placeholder = 'Start writing...',
className = '',
editable = true,
}: RichTextEditorProps) {
const lastContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
editable,
onUpdate: ({ editor }) => {
// Convert HTML back to markdown
const markdown = htmlToMarkdown(editor.getHTML());
lastContentRef.current = markdown;
onChange(markdown);
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4',
},
},
});
// Update content when prop changes (e.g., switching languages)
useEffect(() => {
if (editor && content !== lastContentRef.current) {
lastContentRef.current = content;
const html = markdownToHtml(content);
editor.commands.setContent(html);
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn overflow-hidden bg-white', className)}>
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
// Read-only preview component
export function RichTextPreview({
content,
className = '',
}: {
content: string; // Markdown content
className?: string;
}) {
const editor = useEditor({
immediatelyRender: false,
extensions: [StarterKit],
content: markdownToHtml(content),
editable: false,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-4',
},
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(markdownToHtml(content));
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn bg-gray-50', className)}>
<EditorContent editor={editor} />
</div>
);
}