import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import MonacoEditor from '@monaco-editor/react'; import { useAppStore, PostData, EditorMode, MediaData } from '../../store'; import { showToast } from '../Toast'; import { WysiwygEditor } from '../WysiwygEditor'; import { Lightbox, useMarkdownImages } from '../Lightbox'; import { PostLinks } from '../PostLinks'; import { ErrorModal } from '../ErrorModal'; import { SettingsView } from '../SettingsView'; import './Editor.css'; /** * Resolves media references in markdown content to bds-media:// URLs * Matches images by: * 1. Media ID in the path (e.g., /media/2025/01/{id}.jpg) * 2. Original filename (e.g., image.jpg) * 3. Filename pattern (e.g., {id}.jpg) */ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => { if (!content || mediaList.length === 0) return content; // Build lookup maps for efficient matching const byId = new Map(); const byOriginalName = new Map(); const byFilename = new Map(); for (const m of mediaList) { byId.set(m.id, m.id); byOriginalName.set(m.originalName.toLowerCase(), m.id); byFilename.set(m.filename.toLowerCase(), m.id); } // Replace image URLs in markdown return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { // Skip if already using bds-media protocol or external URLs if (src.startsWith('bds-media://') || src.startsWith('http://') || src.startsWith('https://')) { return match; } // Extract the filename from the path const filename = src.split('/').pop() || ''; const filenameWithoutExt = filename.replace(/\.[^.]+$/, ''); const filenameLower = filename.toLowerCase(); // Try to match by: // 1. UUID in path (the file is named by ID) if (byId.has(filenameWithoutExt)) { return `![${alt}](bds-media://${filenameWithoutExt})`; } // 2. Filename lookup if (byFilename.has(filenameLower)) { return `![${alt}](bds-media://${byFilename.get(filenameLower)})`; } // 3. Original name lookup if (byOriginalName.has(filenameLower)) { return `![${alt}](bds-media://${byOriginalName.get(filenameLower)})`; } // No match found, return original return match; }); }; // Simple markdown to HTML converter for preview const markdownToHtml = (markdown: string): string => { return markdown // Escape HTML .replace(//g, '>') // Headers .replace(/^### (.*$)/gim, '

$1

') .replace(/^## (.*$)/gim, '

$1

') .replace(/^# (.*$)/gim, '

$1

') // Bold .replace(/\*\*(.*?)\*\*/gim, '$1') // Italic .replace(/\*(.*?)\*/gim, '$1') // Images .replace(/!\[(.*?)\]\((.*?)\)/gim, '$1') // Links .replace(/\[(.*?)\]\((.*?)\)/gim, '$1') // Code blocks .replace(/```([\s\S]*?)```/gim, '
$1
') // Inline code .replace(/`(.*?)`/gim, '$1') // Blockquotes .replace(/^\> (.*$)/gim, '
$1
') // Horizontal rules .replace(/^---$/gim, '
') // Line breaks .replace(/\n/g, '
'); }; interface PostEditorProps { post: PostData; } const PostEditor: React.FC = ({ post }) => { const { updatePost, markDirty, markClean, isDirty: checkIsDirty, preferredEditorMode, setPreferredEditorMode, showErrorModal, media, } = useAppStore(); const [title, setTitle] = useState(post.title); const [content, setContent] = useState(post.content); const [tags, setTags] = useState(post.tags.join(', ')); const [category, setCategory] = useState(post.categories[0] || 'article'); const [availableCategories, setAvailableCategories] = useState(['article', 'picture', 'aside', 'page']); const [isSaving, setIsSaving] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [editorMode, setEditorMode] = useState(preferredEditorMode); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const editorRef = useRef(null); const isDirty = checkIsDirty(post.id); // Check if post has a published version for discard functionality useEffect(() => { window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion); }, [post.id]); // Load available categories from localStorage useEffect(() => { const savedCategories = localStorage.getItem('bds-categories'); if (savedCategories) { try { const parsed = JSON.parse(savedCategories); if (Array.isArray(parsed) && parsed.length > 0) { setAvailableCategories(parsed); } } catch { // Keep defaults } } }, []); // Resolve media URLs in content for display const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]); // Extract images from resolved content for lightbox const images = useMarkdownImages(resolvedContent); // Track latest values for auto-save on unmount/switch const pendingChangesRef = useRef<{ title: string; content: string; tags: string; category: string; postId: string; isDirty: boolean; } | null>(null); // Update ref when values change useEffect(() => { pendingChangesRef.current = { title, content, tags, category, postId: post.id, isDirty, }; }, [title, content, tags, category, post.id, isDirty]); // Auto-save when switching away from a post or unmounting useEffect(() => { const prevPostId = post.id; return () => { const pending = pendingChangesRef.current; // Only auto-save if the post still exists in the store (not deleted/discarded) const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId); if (pending && pending.postId === prevPostId && pending.isDirty && postStillExists) { // Fire and forget auto-save window.electronAPI?.posts.update(pending.postId, { title: pending.title, content: pending.content, tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), categories: pending.category ? [pending.category] : ['article'], }).then((updated) => { if (updated) { useAppStore.getState().updatePost(pending.postId, updated as Partial); useAppStore.getState().markClean(pending.postId); } }).catch((error) => { console.error('Auto-save failed:', error); }); } }; }, [post.id]); // Reset when post changes (after auto-save cleanup runs) useEffect(() => { setTitle(post.title); setContent(post.content); setTags(post.tags.join(', ')); setCategory(post.categories[0] || 'article'); markClean(post.id); }, [post.id, post.title, post.content, post.tags, post.categories, markClean]); // Track changes useEffect(() => { const currentCategory = post.categories[0] || 'article'; const hasChanges = title !== post.title || content !== post.content || tags !== post.tags.join(', ') || category !== currentCategory; if (hasChanges) { markDirty(post.id); } else { markClean(post.id); } }, [title, content, tags, category, post, markDirty, markClean]); // Handle editor mode change and persist preference const handleEditorModeChange = (mode: EditorMode) => { setEditorMode(mode); setPreferredEditorMode(mode); }; const handleSave = useCallback(async () => { if (!isDirty || isSaving) return; setIsSaving(true); try { const updated = await window.electronAPI?.posts.update(post.id, { title, content, tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), categories: category ? [category] : ['article'], }); if (updated) { updatePost(post.id, updated as Partial); markClean(post.id); } } catch (error) { console.error('Failed to save post:', error); const err = error as Error; showErrorModal({ title: 'Save Failed', message: err.message || 'Failed to save post', stack: err.stack, }); } finally { setIsSaving(false); } }, [post.id, title, content, tags, category, isDirty, isSaving, updatePost, markClean, showErrorModal]); const handlePublish = async () => { await handleSave(); try { const updated = await window.electronAPI?.posts.publish(post.id); if (updated) { updatePost(post.id, updated as Partial); showToast.success('Post published'); } } catch (error) { console.error('Failed to publish post:', error); const err = error as Error; showErrorModal({ title: 'Publish Failed', message: err.message || 'Failed to publish post', stack: err.stack, }); } }; const handleUnpublish = async () => { try { const updated = await window.electronAPI?.posts.unpublish(post.id); if (updated) { updatePost(post.id, updated as Partial); showToast.success('Post unpublished'); } } catch (error) { console.error('Failed to unpublish post:', error); const err = error as Error; showErrorModal({ title: 'Unpublish Failed', message: err.message || 'Failed to unpublish post', stack: err.stack, }); } }; const handleDiscard = async () => { // If this post has a published version, revert to it // If never published, delete the post entirely const confirmMessage = hasPublishedVersion ? 'Discard all changes since last publish? This cannot be undone.' : 'Delete this draft? This cannot be undone.'; if (!confirm(confirmMessage)) { return; } try { if (hasPublishedVersion) { // Revert to published version const reverted = await window.electronAPI?.posts.discard(post.id); if (reverted) { setTitle(reverted.title); setContent(reverted.content); setTags(reverted.tags.join(', ')); setCategory(reverted.categories[0] || 'article'); updatePost(post.id, reverted as Partial); markClean(post.id); showToast.success('Reverted to last published version'); } } else { // Never published - delete the post entirely await window.electronAPI?.posts.delete(post.id); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(post.id); useAppStore.getState().setSelectedPost(null); showToast.success('Draft deleted'); } } catch (error) { console.error('Failed to discard/delete:', error); const err = error as Error; showErrorModal({ title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed', message: err.message || 'Operation failed', stack: err.stack, }); } }; const handleDelete = async () => { if (confirm('Are you sure you want to delete this post?')) { try { await window.electronAPI?.posts.delete(post.id); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(post.id); useAppStore.getState().setSelectedPost(null); showToast.success('Post deleted'); } catch (error) { console.error('Failed to delete post:', error); const err = error as Error; showErrorModal({ title: 'Delete Failed', message: err.message || 'Failed to delete post', stack: err.stack, }); } } }; // Handle Monaco editor mount const handleEditorDidMount = (editor: unknown) => { editorRef.current = editor; }; // Save on Ctrl+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSave]); // Listen for menu events useEffect(() => { const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave); const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish); const unsubscribeUnpublish = window.electronAPI?.on('menu:unpublishSelected', handleUnpublish); return () => { unsubscribeSave?.(); unsubscribePublish?.(); unsubscribeUnpublish?.(); }; }, [handleSave]); return (
{title || 'Untitled'} {isDirty && }
{post.status} {isSaving && Saving...} {post.status === 'draft' ? ( ) : ( )} {post.status === 'draft' && ( )} {post.status === 'published' && ( )}
setTitle(e.target.value)} placeholder="Untitled" />
setTags(e.target.value)} placeholder="tag1, tag2, tag3" />
useAppStore.getState().setSelectedPost(id)} />
{images.length > 0 && ( )}
{editorMode === 'wysiwyg' && ( )} {editorMode === 'markdown' && ( setContent(value || '')} onMount={handleEditorDidMount} theme="vs-dark" options={{ minimap: { enabled: false }, wordWrap: 'on', lineNumbers: 'on', fontSize: 14, fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", padding: { top: 12, bottom: 12 }, automaticLayout: true, scrollBeyondLastLine: false, renderLineHighlight: 'line', quickSuggestions: false, formatOnPaste: true, cursorStyle: 'line', cursorBlinking: 'smooth', }} /> )} {editorMode === 'preview' && (
)}
{/* Lightbox for viewing images in content */} setLightboxOpen(false)} />
Created: {new Date(post.createdAt).toLocaleString()} Updated: {new Date(post.updatedAt).toLocaleString()} {post.publishedAt && ( Published: {new Date(post.publishedAt).toLocaleString()} )}
); }; const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { const { media, updateMedia, showErrorModal } = useAppStore(); const item = media.find(m => m.id === mediaId); const [alt, setAlt] = useState(item?.alt || ''); const [caption, setCaption] = useState(item?.caption || ''); const [tags, setTags] = useState(item?.tags.join(', ') || ''); useEffect(() => { if (item) { setAlt(item.alt || ''); setCaption(item.caption || ''); setTags(item.tags.join(', ')); } }, [item?.id]); if (!item) { return
Media not found
; } const handleSave = async () => { try { const updated = await window.electronAPI?.media.update(item.id, { alt, caption, tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), }); if (updated) { updateMedia(item.id, updated as Partial); showToast.success('Media updated'); } } catch (error) { console.error('Failed to update media:', error); const err = error as Error; showErrorModal({ title: 'Update Failed', message: err.message || 'Failed to update media', stack: err.stack, }); } }; const handleDelete = async () => { if (confirm('Are you sure you want to delete this media file?')) { try { await window.electronAPI?.media.delete(item.id); useAppStore.getState().removeMedia(item.id); showToast.success('Media deleted'); } catch (error) { console.error('Failed to delete media:', error); const err = error as Error; showErrorModal({ title: 'Delete Failed', message: err.message || 'Failed to delete media', stack: err.stack, }); } } }; return (
{item.originalName}
{item.mimeType.startsWith('image/') ? (
{item.originalName}
) : (
{item.originalName}
)}
{item.width && item.height && (
)}
setAlt(e.target.value)} placeholder="Describe the image for accessibility" />