feat: even more feature implementations
This commit is contained in:
@@ -206,3 +206,12 @@ the correct styling of the website.
|
|||||||
|
|
||||||
Publishing of files can be configured to be done via FTP or SSH, connection data must be configureable in
|
Publishing of files can be configured to be done via FTP or SSH, connection data must be configureable in
|
||||||
preferences for the website.
|
preferences for the website.
|
||||||
|
|
||||||
|
## Editing II
|
||||||
|
|
||||||
|
I also want an outline-based editor that integrates well with markdown, so that I can edit longer posts and
|
||||||
|
pages in an outline, with allowing to fold down sections on same levels to get a better overview of the
|
||||||
|
overall story. This is important for bigger stories that require more focus on the overall writing.
|
||||||
|
|
||||||
|
The stories still should be stored as markdown, so the outliner should be an alternative editor that can be
|
||||||
|
chosen in the same way as the wysiwyg and the raw markdown editor.
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tiptap/extension-image": "^3.19.0",
|
"@tiptap/extension-image": "^3.19.0",
|
||||||
@@ -1592,17 +1593,26 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.10"
|
"@floating-ui/utils": "^0.2.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.4",
|
||||||
|
"@floating-ui/utils": "^0.2.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/utils": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.9",
|
"version": "1.19.9",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tiptap/extension-image": "^3.19.0",
|
"@tiptap/extension-image": "^3.19.0",
|
||||||
|
|||||||
@@ -72,6 +72,25 @@
|
|||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-badge.status-unsaved {
|
||||||
|
background-color: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tab-badge {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tab-badge.new {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-actions button {
|
.editor-actions button {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { useAppStore, PostData } from '../../store';
|
import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { WysiwygEditor } from '../WysiwygEditor';
|
import { WysiwygEditor } from '../WysiwygEditor';
|
||||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||||
import { PostLinks } from '../PostLinks';
|
import { PostLinks } from '../PostLinks';
|
||||||
|
import { ErrorModal } from '../ErrorModal';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
// Simple markdown to HTML converter for preview
|
// Simple markdown to HTML converter for preview
|
||||||
@@ -37,24 +38,36 @@ const markdownToHtml = (markdown: string): string => {
|
|||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorMode = 'markdown' | 'wysiwyg' | 'preview';
|
// Check if an ID is for an unsaved draft
|
||||||
|
const isUnsavedDraftId = (id: string): boolean => id.startsWith('draft-');
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface SavedPostEditorProps {
|
||||||
post: PostData;
|
post: PostData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||||
const { updatePost } = useAppStore();
|
const {
|
||||||
|
updatePost,
|
||||||
|
markDirty,
|
||||||
|
markClean,
|
||||||
|
isDirty: checkIsDirty,
|
||||||
|
preferredEditorMode,
|
||||||
|
setPreferredEditorMode,
|
||||||
|
showErrorModal,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
const [title, setTitle] = useState(post.title);
|
const [title, setTitle] = useState(post.title);
|
||||||
const [content, setContent] = useState(post.content);
|
const [content, setContent] = useState(post.content);
|
||||||
const [tags, setTags] = useState(post.tags.join(', '));
|
const [tags, setTags] = useState(post.tags.join(', '));
|
||||||
const [categories, setCategories] = useState(post.categories.join(', '));
|
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<EditorMode>('wysiwyg');
|
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
const editorRef = useRef<unknown>(null);
|
const editorRef = useRef<unknown>(null);
|
||||||
|
|
||||||
|
const isDirty = checkIsDirty(post.id);
|
||||||
|
|
||||||
// Extract images from content for lightbox
|
// Extract images from content for lightbox
|
||||||
const images = useMarkdownImages(content);
|
const images = useMarkdownImages(content);
|
||||||
|
|
||||||
@@ -64,8 +77,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
setContent(post.content);
|
setContent(post.content);
|
||||||
setTags(post.tags.join(', '));
|
setTags(post.tags.join(', '));
|
||||||
setCategories(post.categories.join(', '));
|
setCategories(post.categories.join(', '));
|
||||||
setIsDirty(false);
|
markClean(post.id);
|
||||||
}, [post.id]);
|
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
|
||||||
|
|
||||||
// Track changes
|
// Track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,12 +87,24 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
content !== post.content ||
|
content !== post.content ||
|
||||||
tags !== post.tags.join(', ') ||
|
tags !== post.tags.join(', ') ||
|
||||||
categories !== post.categories.join(', ');
|
categories !== post.categories.join(', ');
|
||||||
setIsDirty(hasChanges);
|
|
||||||
}, [title, content, tags, categories, post]);
|
if (hasChanges) {
|
||||||
|
markDirty(post.id);
|
||||||
|
} else {
|
||||||
|
markClean(post.id);
|
||||||
|
}
|
||||||
|
}, [title, content, tags, categories, post, markDirty, markClean]);
|
||||||
|
|
||||||
|
// Handle editor mode change and persist preference
|
||||||
|
const handleEditorModeChange = (mode: EditorMode) => {
|
||||||
|
setEditorMode(mode);
|
||||||
|
setPreferredEditorMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!isDirty) return;
|
if (!isDirty || isSaving) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await window.electronAPI?.posts.update(post.id, {
|
const updated = await window.electronAPI?.posts.update(post.id, {
|
||||||
title,
|
title,
|
||||||
@@ -90,14 +115,21 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(post.id, updated as Partial<PostData>);
|
updatePost(post.id, updated as Partial<PostData>);
|
||||||
setIsDirty(false);
|
markClean(post.id);
|
||||||
showToast.success('Post saved');
|
showToast.success('Post saved');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save post:', error);
|
console.error('Failed to save post:', error);
|
||||||
showToast.error('Failed to save post');
|
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, categories, isDirty, updatePost]);
|
}, [post.id, title, content, tags, categories, isDirty, isSaving, updatePost, markClean, showErrorModal]);
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
await handleSave();
|
await handleSave();
|
||||||
@@ -109,7 +141,12 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to publish post:', error);
|
console.error('Failed to publish post:', error);
|
||||||
showToast.error('Failed to publish post');
|
const err = error as Error;
|
||||||
|
showErrorModal({
|
||||||
|
title: 'Publish Failed',
|
||||||
|
message: err.message || 'Failed to publish post',
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +159,12 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to unpublish post:', error);
|
console.error('Failed to unpublish post:', error);
|
||||||
showToast.error('Failed to unpublish post');
|
const err = error as Error;
|
||||||
|
showErrorModal({
|
||||||
|
title: 'Unpublish Failed',
|
||||||
|
message: err.message || 'Failed to unpublish post',
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +177,12 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
showToast.success('Post deleted');
|
showToast.success('Post deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete post:', error);
|
console.error('Failed to delete post:', error);
|
||||||
showToast.error('Failed to delete post');
|
const err = error as Error;
|
||||||
|
showErrorModal({
|
||||||
|
title: 'Delete Failed',
|
||||||
|
message: err.message || 'Failed to delete post',
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -176,7 +223,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
<div className="editor-header">
|
<div className="editor-header">
|
||||||
<div className="editor-tabs">
|
<div className="editor-tabs">
|
||||||
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
||||||
<span className="editor-tab-title">{post.title || 'Untitled'}</span>
|
<span className="editor-tab-title">{title || 'Untitled'}</span>
|
||||||
{isDirty && <span className="editor-tab-dirty">●</span>}
|
{isDirty && <span className="editor-tab-dirty">●</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +238,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
Unpublish
|
Unpublish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleSave} disabled={!isDirty} title="Save (Ctrl+S)">
|
<button onClick={handleSave} disabled={!isDirty || isSaving} title="Save (Ctrl+S)">
|
||||||
Save
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete} className="secondary danger" title="Delete">
|
<button onClick={handleDelete} className="secondary danger" title="Delete">
|
||||||
Delete
|
Delete
|
||||||
@@ -253,21 +300,21 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
<div className="editor-mode-toggle">
|
<div className="editor-mode-toggle">
|
||||||
<button
|
<button
|
||||||
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||||
onClick={() => setEditorMode('wysiwyg')}
|
onClick={() => handleEditorModeChange('wysiwyg')}
|
||||||
title="Visual editor"
|
title="Visual editor"
|
||||||
>
|
>
|
||||||
Visual
|
Visual
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={editorMode === 'markdown' ? 'active' : ''}
|
className={editorMode === 'markdown' ? 'active' : ''}
|
||||||
onClick={() => setEditorMode('markdown')}
|
onClick={() => handleEditorModeChange('markdown')}
|
||||||
title="Markdown source"
|
title="Markdown source"
|
||||||
>
|
>
|
||||||
Markdown
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={editorMode === 'preview' ? 'active' : ''}
|
className={editorMode === 'preview' ? 'active' : ''}
|
||||||
onClick={() => setEditorMode('preview')}
|
onClick={() => handleEditorModeChange('preview')}
|
||||||
title="Read-only preview"
|
title="Read-only preview"
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
@@ -354,8 +401,316 @@ const PostEditor: React.FC<PostEditorProps> = ({ 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 MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||||
const { media, updateMedia } = useAppStore();
|
const { media, updateMedia, showErrorModal } = useAppStore();
|
||||||
const item = media.find(m => m.id === mediaId);
|
const item = media.find(m => m.id === mediaId);
|
||||||
|
|
||||||
const [alt, setAlt] = useState(item?.alt || '');
|
const [alt, setAlt] = useState(item?.alt || '');
|
||||||
@@ -383,9 +738,16 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
});
|
});
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updateMedia(item.id, updated as Partial<typeof item>);
|
updateMedia(item.id, updated as Partial<typeof item>);
|
||||||
|
showToast.success('Media updated');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update media:', 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,8 +756,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
try {
|
try {
|
||||||
await window.electronAPI?.media.delete(item.id);
|
await window.electronAPI?.media.delete(item.id);
|
||||||
useAppStore.getState().removeMedia(item.id);
|
useAppStore.getState().removeMedia(item.id);
|
||||||
|
showToast.success('Media deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete media:', 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -488,6 +857,13 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const WelcomeScreen: React.FC = () => {
|
const WelcomeScreen: React.FC = () => {
|
||||||
|
const { createUnsavedDraft, setSelectedPost } = useAppStore();
|
||||||
|
|
||||||
|
const handleNewPost = () => {
|
||||||
|
const draftId = createUnsavedDraft();
|
||||||
|
setSelectedPost(draftId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-empty">
|
<div className="editor-empty">
|
||||||
<div className="welcome-content">
|
<div className="welcome-content">
|
||||||
@@ -498,7 +874,7 @@ const WelcomeScreen: React.FC = () => {
|
|||||||
<div className="welcome-action">
|
<div className="welcome-action">
|
||||||
<h3>Create a New Post</h3>
|
<h3>Create a New Post</h3>
|
||||||
<p>Start writing your next blog post with Markdown support.</p>
|
<p>Start writing your next blog post with Markdown support.</p>
|
||||||
<button onClick={() => window.electronAPI?.posts.create({ title: 'New Post' })}>
|
<button onClick={handleNewPost}>
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -545,18 +921,60 @@ const WelcomeScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Editor: React.FC = () => {
|
export const Editor: React.FC = () => {
|
||||||
const { activeView, selectedPostId, selectedMediaId, posts } = useAppStore();
|
const {
|
||||||
|
activeView,
|
||||||
|
selectedPostId,
|
||||||
|
selectedMediaId,
|
||||||
|
posts,
|
||||||
|
unsavedDrafts,
|
||||||
|
errorModal,
|
||||||
|
hideErrorModal,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Show error modal if present
|
||||||
|
const renderErrorModal = () => (
|
||||||
|
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
||||||
|
);
|
||||||
|
|
||||||
if (activeView === 'posts' && selectedPostId) {
|
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);
|
const post = posts.find(p => p.id === selectedPostId);
|
||||||
if (post) {
|
if (post) {
|
||||||
return <PostEditor post={post} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SavedPostEditor post={post} />
|
||||||
|
{renderErrorModal()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeView === 'media' && selectedMediaId) {
|
if (activeView === 'media' && selectedMediaId) {
|
||||||
return <MediaEditor mediaId={selectedMediaId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<MediaEditor mediaId={selectedMediaId} />
|
||||||
|
{renderErrorModal()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WelcomeScreen />;
|
return (
|
||||||
|
<>
|
||||||
|
<WelcomeScreen />
|
||||||
|
{renderErrorModal()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
135
src/renderer/components/ErrorModal/ErrorModal.css
Normal file
135
src/renderer/components/ErrorModal/ErrorModal.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
.error-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal {
|
||||||
|
background: var(--color-bg-secondary, #1e1e1e);
|
||||||
|
border: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-error, #f14c4c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-close:hover {
|
||||||
|
background: var(--color-bg-tertiary, #2a2a2a);
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text, #ccc);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-stack-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-stack-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-stack-header span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
background: var(--color-bg-tertiary, #2a2a2a);
|
||||||
|
border: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
color: var(--color-text, #ccc);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background: var(--color-bg-hover, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-stack {
|
||||||
|
background: var(--color-bg-primary, #0d0d0d);
|
||||||
|
border: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-footer button {
|
||||||
|
background: var(--color-primary, #0e639c);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-modal-footer button:hover {
|
||||||
|
background: var(--color-primary-hover, #1177bb);
|
||||||
|
}
|
||||||
62
src/renderer/components/ErrorModal/ErrorModal.tsx
Normal file
62
src/renderer/components/ErrorModal/ErrorModal.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import './ErrorModal.css';
|
||||||
|
|
||||||
|
export interface ErrorDetails {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorModalProps {
|
||||||
|
error: ErrorDetails | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
const handleCopyStack = useCallback(async () => {
|
||||||
|
const textToCopy = `${error.title || 'Error'}\n${error.message}\n\nStack Trace:\n${error.stack || 'No stack trace available'}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy to clipboard:', err);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="error-modal">
|
||||||
|
<div className="error-modal-header">
|
||||||
|
<h2>{error.title || 'Error'}</h2>
|
||||||
|
<button className="error-modal-close" onClick={onClose} title="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="error-modal-body">
|
||||||
|
<div className="error-message">{error.message}</div>
|
||||||
|
{error.stack && (
|
||||||
|
<div className="error-stack-section">
|
||||||
|
<div className="error-stack-header">
|
||||||
|
<span>Stack Trace</span>
|
||||||
|
<button className="copy-button" onClick={handleCopyStack} title="Copy to clipboard">
|
||||||
|
📋 Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="error-stack">{error.stack}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="error-modal-footer">
|
||||||
|
<button onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
src/renderer/components/ErrorModal/index.ts
Normal file
2
src/renderer/components/ErrorModal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ErrorModal } from './ErrorModal';
|
||||||
|
export type { ErrorDetails } from './ErrorModal';
|
||||||
@@ -495,3 +495,28 @@
|
|||||||
.filter-status button:hover {
|
.filter-status button:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Unsaved drafts styling */
|
||||||
|
.status-unsaved {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.unsaved {
|
||||||
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.unsaved .sidebar-item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsaved-indicator {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.unsaved .sidebar-item-meta {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAppStore, PostData } from '../../store';
|
import { useAppStore, PostData, UnsavedDraft } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PostsList: React.FC = () => {
|
const PostsList: React.FC = () => {
|
||||||
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore();
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -321,20 +321,11 @@ const PostsList: React.FC = () => {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}, [selectedTags, selectedCategories]);
|
}, [selectedTags, selectedCategories]);
|
||||||
|
|
||||||
const handleCreatePost = async () => {
|
const handleCreatePost = () => {
|
||||||
try {
|
// Create an unsaved draft instead of immediately saving to database
|
||||||
const newPost = await window.electronAPI?.posts.create({
|
const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState();
|
||||||
title: 'Untitled Post',
|
const draftId = createUnsavedDraft();
|
||||||
content: '# New Post\n\nStart writing your content here...',
|
selectPost(draftId);
|
||||||
});
|
|
||||||
if (newPost) {
|
|
||||||
setSelectedPost((newPost as PostData).id);
|
|
||||||
showToast.success('Post created');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create post:', error);
|
|
||||||
showToast.error('Failed to create post');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine which posts to display
|
// Determine which posts to display
|
||||||
@@ -414,6 +405,34 @@ const PostsList: React.FC = () => {
|
|||||||
</div>
|
</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 && (
|
{groupedPosts.draft.length > 0 && (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-title">
|
<div className="sidebar-section-title">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
|
||||||
|
import { FloatingMenu } from '@tiptap/extension-floating-menu';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { TaskPopup } from './TaskPopup';
|
|||||||
export { ResizablePanel } from './ResizablePanel';
|
export { ResizablePanel } from './ResizablePanel';
|
||||||
export { CredentialsPanel } from './CredentialsPanel';
|
export { CredentialsPanel } from './CredentialsPanel';
|
||||||
export { PostLinks } from './PostLinks';
|
export { PostLinks } from './PostLinks';
|
||||||
|
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ export interface PostData {
|
|||||||
categories: string[];
|
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 {
|
export interface MediaData {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -55,6 +66,14 @@ export interface TaskProgress {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ErrorDetails {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
|
||||||
|
|
||||||
// App State Store
|
// App State Store
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -67,12 +86,21 @@ interface AppState {
|
|||||||
panelVisible: boolean;
|
panelVisible: boolean;
|
||||||
selectedPostId: string | null;
|
selectedPostId: string | null;
|
||||||
selectedMediaId: string | null;
|
selectedMediaId: string | null;
|
||||||
|
preferredEditorMode: EditorMode;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
posts: PostData[];
|
posts: PostData[];
|
||||||
media: MediaData[];
|
media: MediaData[];
|
||||||
tasks: TaskProgress[];
|
tasks: TaskProgress[];
|
||||||
|
|
||||||
|
// Unsaved drafts (memory only until saved)
|
||||||
|
unsavedDrafts: UnsavedDraft[];
|
||||||
|
// Track which posts have unsaved changes (by post ID or draft ID)
|
||||||
|
dirtyPosts: Set<string>;
|
||||||
|
|
||||||
|
// Error modal
|
||||||
|
errorModal: ErrorDetails | null;
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
syncStatus: 'idle' | 'syncing' | 'error';
|
syncStatus: 'idle' | 'syncing' | 'error';
|
||||||
syncConfigured: boolean;
|
syncConfigured: boolean;
|
||||||
@@ -95,12 +123,28 @@ interface AppState {
|
|||||||
togglePanel: () => void;
|
togglePanel: () => void;
|
||||||
setSelectedPost: (id: string | null) => void;
|
setSelectedPost: (id: string | null) => void;
|
||||||
setSelectedMedia: (id: string | null) => void;
|
setSelectedMedia: (id: string | null) => void;
|
||||||
|
setPreferredEditorMode: (mode: EditorMode) => void;
|
||||||
|
|
||||||
setPosts: (posts: PostData[]) => void;
|
setPosts: (posts: PostData[]) => void;
|
||||||
addPost: (post: PostData) => void;
|
addPost: (post: PostData) => void;
|
||||||
updatePost: (id: string, post: Partial<PostData>) => void;
|
updatePost: (id: string, post: Partial<PostData>) => void;
|
||||||
removePost: (id: string) => 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;
|
||||||
|
isDirty: (id: string) => boolean;
|
||||||
|
|
||||||
|
// Error modal actions
|
||||||
|
showErrorModal: (error: ErrorDetails) => void;
|
||||||
|
hideErrorModal: () => void;
|
||||||
|
|
||||||
setMedia: (media: MediaData[]) => void;
|
setMedia: (media: MediaData[]) => void;
|
||||||
addMedia: (media: MediaData) => void;
|
addMedia: (media: MediaData) => void;
|
||||||
updateMedia: (id: string, media: Partial<MediaData>) => void;
|
updateMedia: (id: string, media: Partial<MediaData>) => void;
|
||||||
@@ -119,7 +163,7 @@ interface AppState {
|
|||||||
|
|
||||||
export const useAppStore = create<AppState>()(
|
export const useAppStore = create<AppState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
// Initial Project State
|
// Initial Project State
|
||||||
projects: [],
|
projects: [],
|
||||||
activeProject: null,
|
activeProject: null,
|
||||||
@@ -130,12 +174,20 @@ export const useAppStore = create<AppState>()(
|
|||||||
panelVisible: false,
|
panelVisible: false,
|
||||||
selectedPostId: null,
|
selectedPostId: null,
|
||||||
selectedMediaId: null,
|
selectedMediaId: null,
|
||||||
|
preferredEditorMode: 'wysiwyg',
|
||||||
|
|
||||||
// Initial Data
|
// Initial Data
|
||||||
posts: [],
|
posts: [],
|
||||||
media: [],
|
media: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
|
|
||||||
|
// Unsaved drafts
|
||||||
|
unsavedDrafts: [],
|
||||||
|
dirtyPosts: new Set<string>(),
|
||||||
|
|
||||||
|
// Error modal
|
||||||
|
errorModal: null,
|
||||||
|
|
||||||
// Initial Sync State
|
// Initial Sync State
|
||||||
syncStatus: 'idle',
|
syncStatus: 'idle',
|
||||||
syncConfigured: false,
|
syncConfigured: false,
|
||||||
@@ -162,6 +214,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||||
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
||||||
|
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||||
|
|
||||||
// Post Actions
|
// Post Actions
|
||||||
setPosts: (posts) => set({ posts }),
|
setPosts: (posts) => set({ posts }),
|
||||||
@@ -174,6 +227,61 @@ export const useAppStore = create<AppState>()(
|
|||||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
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) => {
|
||||||
|
const newDirtyPosts = new Set(state.dirtyPosts);
|
||||||
|
newDirtyPosts.delete(id);
|
||||||
|
return {
|
||||||
|
unsavedDrafts: state.unsavedDrafts.filter((d) => d.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]),
|
||||||
|
})),
|
||||||
|
|
||||||
|
markClean: (id) => set((state) => {
|
||||||
|
const newDirtyPosts = new Set(state.dirtyPosts);
|
||||||
|
newDirtyPosts.delete(id);
|
||||||
|
return { dirtyPosts: newDirtyPosts };
|
||||||
|
}),
|
||||||
|
|
||||||
|
isDirty: (id) => get().dirtyPosts.has(id),
|
||||||
|
|
||||||
|
// Error modal actions
|
||||||
|
showErrorModal: (error) => set({ errorModal: error }),
|
||||||
|
hideErrorModal: () => set({ errorModal: null }),
|
||||||
|
|
||||||
// Media Actions
|
// Media Actions
|
||||||
setMedia: (media) => set({ media }),
|
setMedia: (media) => set({ media }),
|
||||||
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
|
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
|
||||||
@@ -209,7 +317,21 @@ export const useAppStore = create<AppState>()(
|
|||||||
panelVisible: state.panelVisible,
|
panelVisible: state.panelVisible,
|
||||||
selectedPostId: state.selectedPostId,
|
selectedPostId: state.selectedPostId,
|
||||||
selectedMediaId: state.selectedMediaId,
|
selectedMediaId: state.selectedMediaId,
|
||||||
|
preferredEditorMode: state.preferredEditorMode,
|
||||||
|
// Persist unsaved drafts for recovery
|
||||||
|
unsavedDrafts: state.unsavedDrafts,
|
||||||
|
// Convert Set to array for storage
|
||||||
|
dirtyPosts: [...state.dirtyPosts],
|
||||||
}),
|
}),
|
||||||
|
// Merge function to restore Set from array
|
||||||
|
merge: (persisted, current) => {
|
||||||
|
const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[] };
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...persistedState,
|
||||||
|
dirtyPosts: new Set(persistedState.dirtyPosts || []),
|
||||||
|
};
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
export { useAppStore, type ProjectData, type PostData, type MediaData, type TaskProgress } from './appStore';
|
export {
|
||||||
|
useAppStore,
|
||||||
|
type ProjectData,
|
||||||
|
type PostData,
|
||||||
|
type MediaData,
|
||||||
|
type TaskProgress,
|
||||||
|
type UnsavedDraft,
|
||||||
|
type EditorMode,
|
||||||
|
type ErrorDetails
|
||||||
|
} from './appStore';
|
||||||
|
|||||||
Reference in New Issue
Block a user