- Add dbGet/dbAll helper functions for database-agnostic queries - Add toDbBool/convertBooleansForDb for boolean type conversion - Add toDbDate/getNow for timestamp type handling - Add generateId that returns UUID for Postgres, nanoid for SQLite - Update all routes to use compatibility helpers - Add normalizeEvent to return clean number types from Postgres decimal - Add formatPrice utility for consistent price display - Add legal pages admin interface with RichTextEditor - Update carousel images - Add drizzle migration files for PostgreSQL
418 lines
13 KiB
TypeScript
418 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(/&/g, '&');
|
|
md = md.replace(/</g, '<');
|
|
md = md.replace(/>/g, '>');
|
|
md = md.replace(/"/g, '"');
|
|
md = md.replace(/'/g, "'");
|
|
md = md.replace(/ /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({
|
|
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({
|
|
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>
|
|
);
|
|
}
|