Files
thrilltrack-explorer/src/components/admin/MarkdownEditor.tsx
gpt-engineer-app[bot] 41f4e3b920 Fix ESLint errors
2025-10-29 23:27:37 +00:00

238 lines
7.4 KiB
TypeScript

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<void>;
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<MDXEditorMethods>(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 (
<div
className="border border-input rounded-lg bg-muted/50 flex items-center justify-center"
style={{ height }}
>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
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 (
<div className="space-y-2">
<div
className="border border-input rounded-lg overflow-hidden"
style={{ minHeight: height }}
>
<MDXEditor
ref={editorRef}
className={cn('mdxeditor', resolvedTheme === 'dark' && 'dark-theme')}
markdown={value}
onChange={onChange}
placeholder={placeholder}
contentEditableClassName="prose dark:prose-invert max-w-none p-4 min-h-[500px] mdx-content-area"
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
linkPlugin(),
linkDialogPlugin(),
imagePlugin({
imageUploadHandler: async (file: File): Promise<string> => {
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: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<CodeToggle />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertTable />
<InsertThematicBreak />
<DiffSourceToggleWrapper>
<span className="text-sm">Source</span>
</DiffSourceToggleWrapper>
</>
)
})
]}
/>
</div>
{/* Auto-save status indicator */}
{autoSave && onSave && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{isSaving && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
)}
{!isSaving && lastSaved && !error && (
<>
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span>{getLastSavedText()}</span>
</>
)}
{error && (
<>
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">Failed to save: {error}</span>
</>
)}
</div>
)}
{/* Word and character count */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>Supports markdown formatting with live preview</span>
<span>
{value.split(/\s+/).filter(Boolean).length} words · {value.length} characters
</span>
</div>
</div>
);
}