feat: even more feature implementations

This commit is contained in:
2026-02-10 14:00:04 +01:00
parent 9f35e74d0f
commit 46970de656
14 changed files with 886 additions and 52 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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()}
</>
);
}; };

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

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

View File

@@ -0,0 +1,2 @@
export { ErrorModal } from './ErrorModal';
export type { ErrorDetails } from './ErrorModal';

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 || []),
};
},
} }
) )
); );

View File

@@ -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';