feat: Integrate Markdown editor

This commit is contained in:
gpt-engineer-app[bot]
2025-10-17 17:30:00 +00:00
parent 026f402057
commit e6ec2c363a
7 changed files with 1317 additions and 11 deletions

1005
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,8 @@
"@supabase/supabase-js": "^2.57.4", "@supabase/supabase-js": "^2.57.4",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@uiw/react-markdown-preview": "^5.1.5",
"@uiw/react-md-editor": "^4.0.8",
"@uppy/core": "^5.0.2", "@uppy/core": "^5.0.2",
"@uppy/dashboard": "^5.0.2", "@uppy/dashboard": "^5.0.2",
"@uppy/image-editor": "^4.0.1", "@uppy/image-editor": "^4.0.1",

View 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>
);
}

View File

@@ -3,6 +3,7 @@ export { AdminPageLayout } from './AdminPageLayout';
export { DesignerForm } from './DesignerForm'; export { DesignerForm } from './DesignerForm';
export { LocationSearch } from './LocationSearch'; export { LocationSearch } from './LocationSearch';
export { ManufacturerForm } from './ManufacturerForm'; export { ManufacturerForm } from './ManufacturerForm';
export { MarkdownEditor } from './MarkdownEditor';
export { NovuMigrationUtility } from './NovuMigrationUtility'; export { NovuMigrationUtility } from './NovuMigrationUtility';
export { OperatorForm } from './OperatorForm'; export { OperatorForm } from './OperatorForm';
export { ParkForm } from './ParkForm'; export { ParkForm } from './ParkForm';

View File

@@ -9,16 +9,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
import { MarkdownEditor } from '@/components/admin/MarkdownEditor';
import { generateSlugFromName } from '@/lib/slugUtils'; import { generateSlugFromName } from '@/lib/slugUtils';
import { extractCloudflareImageId } from '@/lib/cloudflareImageUtils'; import { extractCloudflareImageId } from '@/lib/cloudflareImageUtils';
import { Edit, Trash2, Eye, Plus } from 'lucide-react'; import { Edit, Trash2, Eye, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import '@/styles/markdown-editor.css';
interface BlogPost { interface BlogPost {
id: string; id: string;
@@ -330,17 +331,21 @@ export default function AdminBlog() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="content">Content (Markdown) *</Label> <Label htmlFor="content">Content (Markdown) *</Label>
<Textarea <MarkdownEditor
id="content"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={setContent}
placeholder="Write your post in markdown..." onSave={async (value) => {
rows={20} if (editingPost) {
className="font-mono text-sm" await supabase
.from('blog_posts')
.update({ content: value, updated_at: new Date().toISOString() })
.eq('id', editingPost.id);
}
}}
autoSave={!!editingPost}
height={600}
placeholder="Write your blog post in markdown..."
/> />
<p className="text-xs text-muted-foreground">
Supports markdown formatting: **bold**, *italic*, # Headings, [links](url), etc.
</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">

View File

@@ -0,0 +1,175 @@
/* MDEditor theme integration with shadcn/ui */
/* Container */
.w-md-editor {
@apply rounded-lg border border-input bg-background;
box-shadow: none !important;
font-family: inherit;
}
/* Toolbar */
.w-md-editor-toolbar {
@apply border-b border-border bg-muted/50;
padding: 8px;
}
.w-md-editor-toolbar button,
.w-md-editor-toolbar-divider {
@apply text-foreground;
}
.w-md-editor-toolbar button:hover {
@apply bg-accent text-accent-foreground;
}
.w-md-editor-toolbar button[data-active="true"] {
@apply bg-primary/10 text-primary;
}
.w-md-editor-toolbar ul > li > button {
border-radius: 4px;
}
/* Editor content area */
.w-md-editor-content {
@apply bg-background;
}
.w-md-editor-text-pre,
.w-md-editor-text-input {
@apply bg-background text-foreground;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
}
.w-md-editor-text-pre > code,
.w-md-editor-text-input {
font-size: 14px !important;
line-height: 1.6 !important;
}
/* Preview area */
.w-md-editor-preview {
@apply bg-background;
}
.wmde-markdown {
@apply bg-background text-foreground;
}
.wmde-markdown-color {
@apply bg-background;
}
/* Dark mode specific overrides */
.dark .w-md-editor {
@apply bg-background border-border;
}
.dark .w-md-editor-toolbar {
@apply bg-muted/50 border-border;
}
.dark .w-md-editor-text-pre,
.dark .w-md-editor-text-input {
@apply bg-background text-foreground;
}
.dark .wmde-markdown {
@apply bg-background text-foreground;
}
.dark .wmde-markdown code {
@apply bg-muted text-foreground;
}
.dark .wmde-markdown pre {
@apply bg-muted border border-border;
}
/* Scrollbar styling */
.w-md-editor-text::-webkit-scrollbar,
.w-md-editor-preview::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.w-md-editor-text::-webkit-scrollbar-track,
.w-md-editor-preview::-webkit-scrollbar-track {
@apply bg-muted/30;
}
.w-md-editor-text::-webkit-scrollbar-thumb,
.w-md-editor-preview::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded;
}
.w-md-editor-text::-webkit-scrollbar-thumb:hover,
.w-md-editor-preview::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
/* Typography in preview matches MarkdownRenderer */
.w-md-editor-preview .wmde-markdown {
max-width: none;
}
.w-md-editor-preview .wmde-markdown h1,
.w-md-editor-preview .wmde-markdown h2,
.w-md-editor-preview .wmde-markdown h3,
.w-md-editor-preview .wmde-markdown h4,
.w-md-editor-preview .wmde-markdown h5,
.w-md-editor-preview .wmde-markdown h6 {
@apply font-bold tracking-tight;
}
.w-md-editor-preview .wmde-markdown h1 {
@apply text-4xl;
}
.w-md-editor-preview .wmde-markdown h2 {
@apply text-3xl;
}
.w-md-editor-preview .wmde-markdown h3 {
@apply text-2xl;
}
.w-md-editor-preview .wmde-markdown p {
@apply text-base leading-relaxed;
}
.w-md-editor-preview .wmde-markdown a {
@apply text-primary no-underline hover:underline;
}
.w-md-editor-preview .wmde-markdown strong {
@apply text-foreground font-semibold;
}
.w-md-editor-preview .wmde-markdown code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm;
}
.w-md-editor-preview .wmde-markdown pre {
@apply bg-muted border border-border rounded;
}
.w-md-editor-preview .wmde-markdown blockquote {
@apply border-l-4 border-primary italic;
}
.w-md-editor-preview .wmde-markdown img {
@apply rounded-lg shadow-lg;
}
.w-md-editor-preview .wmde-markdown hr {
@apply border-border;
}
.w-md-editor-preview .wmde-markdown ul {
@apply list-disc;
}
.w-md-editor-preview .wmde-markdown ol {
@apply list-decimal;
}

View File

@@ -107,5 +107,8 @@ export default {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
],
} satisfies Config; } satisfies Config;