mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 09:51:13 -05:00
feat: Integrate Markdown editor
This commit is contained in:
115
src/components/admin/MarkdownEditor.tsx
Normal file
115
src/components/admin/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import { useTheme } from '@/components/theme/ThemeProvider';
|
||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||
import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
||||
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) {
|
||||
const { theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 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 = () => {
|
||||
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 data-color-mode={theme}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={(val) => onChange(val || '')}
|
||||
height={height}
|
||||
preview="live"
|
||||
hideToolbar={false}
|
||||
enableScroll={true}
|
||||
textareaProps={{
|
||||
placeholder
|
||||
}}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
className: 'prose dark:prose-invert max-w-none p-4'
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user