import { useEffect, useState, useRef } from 'react'; import { MDXEditor, headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, codeBlockPlugin, codeMirrorPlugin, diffSourcePlugin, toolbarPlugin, UndoRedo, BoldItalicUnderlineToggles, CodeToggle, ListsToggle, BlockTypeSelect, CreateLink, InsertImage, InsertTable, InsertThematicBreak, DiffSourceToggleWrapper, type MDXEditorMethods } from '@mdxeditor/editor'; import '@mdxeditor/editor/style.css'; import '@/styles/mdx-editor-theme.css'; import { useTheme } from '@/components/theme/ThemeProvider'; import { supabase } from '@/integrations/supabase/client'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; import { useAutoSave } from '@/hooks/useAutoSave'; import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; interface MarkdownEditorProps { value: string; onChange: (value: string) => void; onSave?: (value: string) => Promise; autoSave?: boolean; height?: number; placeholder?: string; } export function MarkdownEditor({ value, onChange, onSave, autoSave = false, height = 600, placeholder = 'Write your content in markdown...' }: MarkdownEditorProps): React.JSX.Element { const { theme } = useTheme(); const [mounted, setMounted] = useState(false); const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); const editorRef = useRef(null); // Resolve "system" theme to actual theme based on OS preference useEffect(() => { if (theme === 'system') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; setResolvedTheme(isDark ? 'dark' : 'light'); } else { setResolvedTheme(theme); } }, [theme]); // Listen for OS theme changes when in system mode useEffect(() => { if (theme !== 'system') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent): void => { setResolvedTheme(e.matches ? 'dark' : 'light'); }; mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); }, [theme]); // Auto-save integration const { isSaving, lastSaved, error } = useAutoSave({ data: value, onSave: onSave || (async () => {}), debounceMs: 3000, enabled: autoSave && !!onSave, isValid: value.length > 0 }); useEffect(() => { setMounted(true); }, []); // Prevent hydration mismatch if (!mounted) { return (
); } const getLastSavedText = (): string | null => { if (!lastSaved) return null; const seconds = Math.floor((Date.now() - lastSaved.getTime()) / 1000); if (seconds < 60) return `Saved ${seconds}s ago`; const minutes = Math.floor(seconds / 60); return `Saved ${minutes}m ago`; }; return (
=> { try { const formData = new FormData(); formData.append('file', file); const { data, error } = await invokeWithTracking( 'upload-image', formData, undefined ); if (error) throw error; // Return CloudFlare imagedelivery.net URL const imageUrl = getCloudflareImageUrl((data as { id: string }).id, 'public'); if (!imageUrl) throw new Error('Failed to generate image URL'); return imageUrl; } catch (error: unknown) { logger.error('Image upload failed', { error: getErrorMessage(error) }); throw new Error(error instanceof Error ? error.message : 'Failed to upload image'); } } }), tablePlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }), codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript (React)', jsx: 'JavaScript (React)', css: 'CSS', html: 'HTML', python: 'Python', bash: 'Bash', json: 'JSON', sql: 'SQL' } }), diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: '' }), toolbarPlugin({ toolbarContents: () => ( <> Source ) }) ]} />
{/* Auto-save status indicator */} {autoSave && onSave && (
{isSaving && ( <> Saving... )} {!isSaving && lastSaved && !error && ( <> {getLastSavedText()} )} {error && ( <> Failed to save: {error} )}
)} {/* Word and character count */}
Supports markdown formatting with live preview {value.split(/\s+/).filter(Boolean).length} words ยท {value.length} characters
); }