feat: more cleanup work in UI
This commit is contained in:
@@ -100,6 +100,12 @@
|
||||
background-color: var(--vscode-notificationsErrorIcon-foreground);
|
||||
}
|
||||
|
||||
.auto-save-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store';
|
||||
import { useAppStore, PostData, EditorMode } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { WysiwygEditor } from '../WysiwygEditor';
|
||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
@@ -38,14 +38,11 @@ const markdownToHtml = (markdown: string): string => {
|
||||
.replace(/\n/g, '<br />');
|
||||
};
|
||||
|
||||
// Check if an ID is for an unsaved draft
|
||||
const isUnsavedDraftId = (id: string): boolean => id.startsWith('draft-');
|
||||
|
||||
interface SavedPostEditorProps {
|
||||
interface PostEditorProps {
|
||||
post: PostData;
|
||||
}
|
||||
|
||||
const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const {
|
||||
updatePost,
|
||||
markDirty,
|
||||
@@ -61,6 +58,7 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
const [tags, setTags] = useState(post.tags.join(', '));
|
||||
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
@@ -68,10 +66,62 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
|
||||
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]);
|
||||
|
||||
// Extract images from content for lightbox
|
||||
const images = useMarkdownImages(content);
|
||||
|
||||
// Reset when post changes
|
||||
// Track latest values for auto-save on unmount/switch
|
||||
const pendingChangesRef = useRef<{
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
categories: string;
|
||||
postId: string;
|
||||
isDirty: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Update ref when values change
|
||||
useEffect(() => {
|
||||
pendingChangesRef.current = {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
categories,
|
||||
postId: post.id,
|
||||
isDirty,
|
||||
};
|
||||
}, [title, content, tags, categories, post.id, isDirty]);
|
||||
|
||||
// Auto-save when switching away from a post or unmounting
|
||||
useEffect(() => {
|
||||
const prevPostId = post.id;
|
||||
|
||||
return () => {
|
||||
const pending = pendingChangesRef.current;
|
||||
if (pending && pending.postId === prevPostId && pending.isDirty) {
|
||||
// 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.categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||
}).then((updated) => {
|
||||
if (updated) {
|
||||
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
|
||||
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);
|
||||
@@ -168,6 +218,48 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
}
|
||||
};
|
||||
|
||||
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(', '));
|
||||
setCategories(reverted.categories.join(', '));
|
||||
updatePost(post.id, reverted as Partial<PostData>);
|
||||
markClean(post.id);
|
||||
showToast.success('Reverted to last published version');
|
||||
}
|
||||
} else {
|
||||
// Never published - delete the post entirely
|
||||
await window.electronAPI?.posts.delete(post.id);
|
||||
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 {
|
||||
@@ -224,24 +316,41 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
<div className="editor-tabs">
|
||||
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
||||
<span className="editor-tab-title">{title || 'Untitled'}</span>
|
||||
{isDirty && <span className="editor-tab-dirty">●</span>}
|
||||
{isDirty && <span className="editor-tab-dirty" title="Unsaved changes (auto-saves on switch)">●</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<span className={`status-badge status-${post.status}`}>
|
||||
{post.status}
|
||||
</span>
|
||||
{isSaving && <span className="auto-save-indicator">Saving...</span>}
|
||||
{post.status === 'draft' ? (
|
||||
<button onClick={handlePublish} title="Publish">Publish</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
className="success"
|
||||
title="Save and make this post public"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
className="secondary"
|
||||
title="Return to draft status"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} disabled={!isDirty || isSaving} title="Save (Ctrl+S)">
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={handleDelete} className="secondary danger" title="Delete">
|
||||
{hasPublishedVersion && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="secondary"
|
||||
title="Revert to last published version"
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -401,314 +510,6 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface UnsavedDraftEditorProps {
|
||||
draft: UnsavedDraft;
|
||||
}
|
||||
|
||||
const UnsavedDraftEditor: React.FC<UnsavedDraftEditorProps> = ({ draft }) => {
|
||||
const {
|
||||
updateUnsavedDraft,
|
||||
removeUnsavedDraft,
|
||||
addPost,
|
||||
setSelectedPost,
|
||||
preferredEditorMode,
|
||||
setPreferredEditorMode,
|
||||
showErrorModal,
|
||||
markClean,
|
||||
} = useAppStore();
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const [content, setContent] = useState(draft.content);
|
||||
const [tags, setTags] = useState(draft.tags.join(', '));
|
||||
const [categories, setCategories] = useState(draft.categories.join(', '));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
// Extract images from content for lightbox
|
||||
const images = useMarkdownImages(content);
|
||||
|
||||
// Update draft in store when local state changes (for recovery purposes)
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
updateUnsavedDraft(draft.id, {
|
||||
title,
|
||||
content,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||
});
|
||||
}, 500); // Debounce updates
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [title, content, tags, categories, draft.id, updateUnsavedDraft]);
|
||||
|
||||
// Handle editor mode change and persist preference
|
||||
const handleEditorModeChange = (mode: EditorMode) => {
|
||||
setEditorMode(mode);
|
||||
setPreferredEditorMode(mode);
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
// Validate - need at least a title
|
||||
if (!title.trim()) {
|
||||
showErrorModal({
|
||||
title: 'Validation Error',
|
||||
message: 'Please enter a title for your post before saving.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Create the post in the database
|
||||
const newPost = await window.electronAPI?.posts.create({
|
||||
title: title.trim(),
|
||||
content,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||
});
|
||||
|
||||
if (newPost) {
|
||||
const postData = newPost as PostData;
|
||||
// Add to posts list
|
||||
addPost(postData);
|
||||
// Remove the unsaved draft
|
||||
removeUnsavedDraft(draft.id);
|
||||
// Select the new post
|
||||
setSelectedPost(postData.id);
|
||||
markClean(postData.id);
|
||||
showToast.success('Post saved');
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}, [title, content, tags, categories, isSaving, draft.id, addPost, removeUnsavedDraft, setSelectedPost, markClean, showErrorModal]);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (title.trim() || content.trim()) {
|
||||
if (!confirm('Are you sure you want to discard this unsaved post?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
removeUnsavedDraft(draft.id);
|
||||
setSelectedPost(null);
|
||||
};
|
||||
|
||||
// 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);
|
||||
return () => {
|
||||
unsubscribeSave?.();
|
||||
};
|
||||
}, [handleSave]);
|
||||
|
||||
const hasContent = title.trim() || content.trim();
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor-header">
|
||||
<div className="editor-tabs">
|
||||
<div className="editor-tab active dirty">
|
||||
<span className="editor-tab-title">{title || 'New Post'}</span>
|
||||
<span className="editor-tab-dirty">●</span>
|
||||
<span className="editor-tab-badge new">NEW</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<span className="status-badge status-unsaved">unsaved</span>
|
||||
<button onClick={handleSave} disabled={isSaving} title="Save (Ctrl+S)">
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={handleDiscard} className="secondary danger" title="Discard">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-content">
|
||||
<div className="editor-meta">
|
||||
<div className="editor-field">
|
||||
<label>Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter post title..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field slug-preview">
|
||||
<label>Slug (auto-generated on save)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title ? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') : ''}
|
||||
disabled
|
||||
className="disabled"
|
||||
placeholder="will-be-generated-from-title"
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Categories (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={categories}
|
||||
onChange={(e) => setCategories(e.target.value)}
|
||||
placeholder="category1, category2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
<div className="editor-toolbar">
|
||||
<label>Content</label>
|
||||
<div className="editor-mode-toggle">
|
||||
<button
|
||||
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('wysiwyg')}
|
||||
title="Visual editor"
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'markdown' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('markdown')}
|
||||
title="Markdown source"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'preview' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('preview')}
|
||||
title="Read-only preview"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<button
|
||||
className="gallery-button"
|
||||
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
||||
title={`View ${images.length} image(s)`}
|
||||
>
|
||||
📷 {images.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorMode === 'wysiwyg' && (
|
||||
<WysiwygEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Start writing your post..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{editorMode === 'markdown' && (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
value={content}
|
||||
onChange={(value) => 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' && (
|
||||
<div className="editor-preview markdown-body">
|
||||
{!content.trim() ? (
|
||||
<div className="preview-empty">
|
||||
<p className="text-muted">No content to preview</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox for viewing images in content */}
|
||||
<Lightbox
|
||||
images={images}
|
||||
initialIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
<span className="text-muted text-small">
|
||||
New post - not yet saved
|
||||
</span>
|
||||
{hasContent && (
|
||||
<span className="text-muted text-small">
|
||||
Press Ctrl+S to save
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||
const { media, updateMedia, showErrorModal } = useAppStore();
|
||||
const item = media.find(m => m.id === mediaId);
|
||||
@@ -857,11 +658,23 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||
};
|
||||
|
||||
const WelcomeScreen: React.FC = () => {
|
||||
const { createUnsavedDraft, setSelectedPost } = useAppStore();
|
||||
const { addPost, setSelectedPost } = useAppStore();
|
||||
|
||||
const handleNewPost = () => {
|
||||
const draftId = createUnsavedDraft();
|
||||
setSelectedPost(draftId);
|
||||
const handleNewPost = async () => {
|
||||
try {
|
||||
const newPost = await window.electronAPI?.posts.create({
|
||||
title: 'Untitled',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
});
|
||||
if (newPost) {
|
||||
addPost(newPost as PostData);
|
||||
setSelectedPost(newPost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -926,9 +739,10 @@ export const Editor: React.FC = () => {
|
||||
selectedPostId,
|
||||
selectedMediaId,
|
||||
posts,
|
||||
unsavedDrafts,
|
||||
errorModal,
|
||||
hideErrorModal,
|
||||
isLoading,
|
||||
setSelectedPost,
|
||||
} = useAppStore();
|
||||
|
||||
// Show error modal if present
|
||||
@@ -937,29 +751,32 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
|
||||
if (activeView === 'posts' && selectedPostId) {
|
||||
// Check if it's an unsaved draft
|
||||
if (isUnsavedDraftId(selectedPostId)) {
|
||||
const draft = unsavedDrafts.find(d => d.id === selectedPostId);
|
||||
if (draft) {
|
||||
return (
|
||||
<>
|
||||
<UnsavedDraftEditor draft={draft} />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, it's a saved post
|
||||
const post = posts.find(p => p.id === selectedPostId);
|
||||
if (post) {
|
||||
return (
|
||||
<>
|
||||
<SavedPostEditor post={post} />
|
||||
<PostEditor post={post} />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Post not found - show loading if still loading, otherwise clear selection
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div className="editor-empty">
|
||||
<div className="welcome-content">
|
||||
<p className="text-muted">Loading post...</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Post truly not found - clear selection and fall through to welcome screen
|
||||
setSelectedPost(null);
|
||||
}
|
||||
|
||||
if (activeView === 'media' && selectedMediaId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore, PostData, UnsavedDraft } from '../../store';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import './Sidebar.css';
|
||||
|
||||
@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
};
|
||||
|
||||
const PostsList: React.FC = () => {
|
||||
const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore();
|
||||
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -321,11 +321,23 @@ const PostsList: React.FC = () => {
|
||||
applyFilters();
|
||||
}, [selectedTags, selectedCategories]);
|
||||
|
||||
const handleCreatePost = () => {
|
||||
// Create an unsaved draft instead of immediately saving to database
|
||||
const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState();
|
||||
const draftId = createUnsavedDraft();
|
||||
selectPost(draftId);
|
||||
const handleCreatePost = async () => {
|
||||
// Create a real post immediately in the database with default empty content
|
||||
try {
|
||||
const { addPost, setSelectedPost: selectPost } = useAppStore.getState();
|
||||
const newPost = await window.electronAPI?.posts.create({
|
||||
title: 'Untitled',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
});
|
||||
if (newPost) {
|
||||
addPost(newPost as PostData);
|
||||
selectPost(newPost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which posts to display
|
||||
@@ -405,34 +417,6 @@ const PostsList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved Drafts Section - always show at top if there are any */}
|
||||
{unsavedDrafts.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
<span className="section-icon status-unsaved">●</span>
|
||||
Unsaved ({unsavedDrafts.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{unsavedDrafts.map((draft: UnsavedDraft) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className={`sidebar-item post-type-new unsaved ${selectedPostId === draft.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(draft.id)}
|
||||
>
|
||||
<span className="post-type-icon" title="New post">✨</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">
|
||||
{draft.title || 'New Post'}
|
||||
<span className="unsaved-indicator">●</span>
|
||||
</div>
|
||||
<div className="sidebar-item-meta">Not yet saved</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedPosts.draft.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
|
||||
import { FloatingMenu } from '@tiptap/extension-floating-menu';
|
||||
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TurndownService from 'turndown';
|
||||
import './WysiwygEditor.css';
|
||||
|
||||
@@ -88,6 +87,10 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
onChange,
|
||||
placeholder = 'Start writing your content...',
|
||||
}) => {
|
||||
// Track if we're updating from internal changes vs external prop changes
|
||||
const isInternalChange = useRef(false);
|
||||
const lastExternalContent = useRef(content);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
@@ -101,34 +104,38 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
class: 'editor-link',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'editor-image',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: markdownToHtml(content),
|
||||
onUpdate: ({ editor }) => {
|
||||
isInternalChange.current = true;
|
||||
const html = editor.getHTML();
|
||||
const markdown = turndownService.turndown(html);
|
||||
onChange(markdown);
|
||||
},
|
||||
editable: true,
|
||||
});
|
||||
|
||||
// Sync content from external changes only (e.g., post switch)
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
const currentHtml = editor.getHTML();
|
||||
const newHtml = markdownToHtml(content);
|
||||
// Only update if content is significantly different
|
||||
if (turndownService.turndown(currentHtml) !== content) {
|
||||
if (editor && content !== lastExternalContent.current) {
|
||||
// This is an external change (e.g., switching posts)
|
||||
if (!isInternalChange.current) {
|
||||
const newHtml = markdownToHtml(content);
|
||||
editor.commands.setContent(newHtml);
|
||||
}
|
||||
lastExternalContent.current = content;
|
||||
isInternalChange.current = false;
|
||||
}
|
||||
}, [content]);
|
||||
}, [content, editor]);
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const url = window.prompt('Enter image URL:');
|
||||
@@ -161,7 +168,7 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
<div className="wysiwyg-editor">
|
||||
{/* Bubble menu appears when text is selected */}
|
||||
{editor && (
|
||||
<BubbleMenu className="bubble-menu" editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<BubbleMenu className="bubble-menu" editor={editor}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||
@@ -206,7 +213,7 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
|
||||
{/* Floating menu appears on empty lines */}
|
||||
{editor && (
|
||||
<FloatingMenu className="floating-menu" editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<FloatingMenu className="floating-menu" editor={editor}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob:; worker-src 'self' blob:; font-src 'self' data:;" />
|
||||
<title>Blogging Desktop Server</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
// Configure Monaco to use local bundled version instead of CDN
|
||||
// This avoids CSP issues in Electron
|
||||
loader.config({ monaco });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -30,17 +30,6 @@ export interface PostData {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
// Unsaved draft that only exists in memory/local storage until saved
|
||||
export interface UnsavedDraft {
|
||||
id: string; // Temporary ID (prefixed with 'draft-')
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
createdAt: string;
|
||||
isNew: true; // Always true for unsaved drafts
|
||||
}
|
||||
|
||||
export interface MediaData {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -93,9 +82,7 @@ interface AppState {
|
||||
media: MediaData[];
|
||||
tasks: TaskProgress[];
|
||||
|
||||
// Unsaved drafts (memory only until saved)
|
||||
unsavedDrafts: UnsavedDraft[];
|
||||
// Track which posts have unsaved changes (by post ID or draft ID)
|
||||
// Track which posts have unsaved changes (by post ID)
|
||||
dirtyPosts: Set<string>;
|
||||
|
||||
// Error modal
|
||||
@@ -130,12 +117,6 @@ interface AppState {
|
||||
updatePost: (id: string, post: Partial<PostData>) => void;
|
||||
removePost: (id: string) => void;
|
||||
|
||||
// Unsaved draft actions
|
||||
createUnsavedDraft: () => string; // Returns the draft ID
|
||||
updateUnsavedDraft: (id: string, data: Partial<UnsavedDraft>) => void;
|
||||
removeUnsavedDraft: (id: string) => void;
|
||||
getUnsavedDraft: (id: string) => UnsavedDraft | undefined;
|
||||
|
||||
// Dirty tracking
|
||||
markDirty: (id: string) => void;
|
||||
markClean: (id: string) => void;
|
||||
@@ -181,8 +162,7 @@ export const useAppStore = create<AppState>()(
|
||||
media: [],
|
||||
tasks: [],
|
||||
|
||||
// Unsaved drafts
|
||||
unsavedDrafts: [],
|
||||
// Dirty posts tracking
|
||||
dirtyPosts: new Set<string>(),
|
||||
|
||||
// Error modal
|
||||
@@ -222,49 +202,16 @@ export const useAppStore = create<AppState>()(
|
||||
updatePost: (id, updatedPost) => set((state) => ({
|
||||
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
|
||||
})),
|
||||
removePost: (id) => set((state) => ({
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
})),
|
||||
|
||||
// Unsaved draft actions
|
||||
createUnsavedDraft: () => {
|
||||
const id = `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const draft: UnsavedDraft = {
|
||||
id,
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
isNew: true,
|
||||
};
|
||||
set((state) => ({
|
||||
unsavedDrafts: [...state.unsavedDrafts, draft],
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateUnsavedDraft: (id, data) => set((state) => ({
|
||||
unsavedDrafts: state.unsavedDrafts.map((d) =>
|
||||
d.id === id ? { ...d, ...data } : d
|
||||
),
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
})),
|
||||
|
||||
removeUnsavedDraft: (id) => set((state) => {
|
||||
removePost: (id) => set((state) => {
|
||||
const newDirtyPosts = new Set(state.dirtyPosts);
|
||||
newDirtyPosts.delete(id);
|
||||
return {
|
||||
unsavedDrafts: state.unsavedDrafts.filter((d) => d.id !== id),
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
dirtyPosts: newDirtyPosts,
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
};
|
||||
}),
|
||||
|
||||
getUnsavedDraft: (id) => get().unsavedDrafts.find((d) => d.id === id),
|
||||
|
||||
// Dirty tracking
|
||||
markDirty: (id) => set((state) => ({
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
@@ -318,8 +265,6 @@ export const useAppStore = create<AppState>()(
|
||||
selectedPostId: state.selectedPostId,
|
||||
selectedMediaId: state.selectedMediaId,
|
||||
preferredEditorMode: state.preferredEditorMode,
|
||||
// Persist unsaved drafts for recovery
|
||||
unsavedDrafts: state.unsavedDrafts,
|
||||
// Convert Set to array for storage
|
||||
dirtyPosts: [...state.dirtyPosts],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
type PostData,
|
||||
type MediaData,
|
||||
type TaskProgress,
|
||||
type UnsavedDraft,
|
||||
type EditorMode,
|
||||
type ErrorDetails
|
||||
} from './appStore';
|
||||
|
||||
@@ -164,6 +164,31 @@ button.secondary:hover {
|
||||
background-color: #4a4d51;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--vscode-button-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
button.success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
button.success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
7
src/renderer/types/electron.d.ts
vendored
7
src/renderer/types/electron.d.ts
vendored
@@ -104,12 +104,19 @@ export interface ElectronAPI {
|
||||
getByStatus: (status: string) => Promise<PostData[]>;
|
||||
publish: (id: string) => Promise<PostData | null>;
|
||||
unpublish: (id: string) => Promise<PostData | null>;
|
||||
discard: (id: string) => Promise<PostData | null>;
|
||||
hasPublishedVersion: (id: string) => Promise<boolean>;
|
||||
rebuildFromFiles: () => Promise<void>;
|
||||
search: (query: string) => Promise<SearchResult[]>;
|
||||
filter: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
getLinksTo: (id: string) => Promise<PostData[]>;
|
||||
getLinkedBy: (id: string) => Promise<PostData[]>;
|
||||
rebuildLinks: () => Promise<void>;
|
||||
isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>;
|
||||
generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||
|
||||
Reference in New Issue
Block a user