mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
feat: Integrate Markdown editor
This commit is contained in:
1005
package-lock.json
generated
1005
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
175
src/styles/markdown-editor.css
Normal file
175
src/styles/markdown-editor.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -107,5 +107,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [
|
||||||
|
require("tailwindcss-animate"),
|
||||||
|
require("@tailwindcss/typography"),
|
||||||
|
],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
Reference in New Issue
Block a user