mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
238 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|