From 46970de65648c2630f96d6f63bc7b216b35a0d05 Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 10 Feb 2026 14:00:04 +0100 Subject: [PATCH] feat: even more feature implementations --- VISION.md | 9 + package-lock.json | 16 +- package.json | 1 + src/renderer/components/Editor/Editor.css | 19 + src/renderer/components/Editor/Editor.tsx | 478 ++++++++++++++++-- .../components/ErrorModal/ErrorModal.css | 135 +++++ .../components/ErrorModal/ErrorModal.tsx | 62 +++ src/renderer/components/ErrorModal/index.ts | 2 + src/renderer/components/Sidebar/Sidebar.css | 25 + src/renderer/components/Sidebar/Sidebar.tsx | 51 +- .../WysiwygEditor/WysiwygEditor.tsx | 4 +- src/renderer/components/index.ts | 1 + src/renderer/store/appStore.ts | 124 ++++- src/renderer/store/index.ts | 11 +- 14 files changed, 886 insertions(+), 52 deletions(-) create mode 100644 src/renderer/components/ErrorModal/ErrorModal.css create mode 100644 src/renderer/components/ErrorModal/ErrorModal.tsx create mode 100644 src/renderer/components/ErrorModal/index.ts diff --git a/VISION.md b/VISION.md index 20e0566..9fe4092 100644 --- a/VISION.md +++ b/VISION.md @@ -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 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. diff --git a/package-lock.json b/package-lock.json index 12d5d0f..d0bb781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@floating-ui/dom": "^1.7.5", "@libsql/client": "^0.4.0", "@monaco-editor/react": "^4.7.0", "@tiptap/extension-image": "^3.19.0", @@ -1592,17 +1593,26 @@ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", - "optional": true, "dependencies": { "@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": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@hono/node-server": { "version": "1.19.9", diff --git a/package.json b/package.json index 1f2524e..9c797dc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "vitest": "^1.0.0" }, "dependencies": { + "@floating-ui/dom": "^1.7.5", "@libsql/client": "^0.4.0", "@monaco-editor/react": "^4.7.0", "@tiptap/extension-image": "^3.19.0", diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 3fbb207..5b1d4fb 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -72,6 +72,25 @@ 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 { padding: 4px 10px; font-size: 12px; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 8570126..e3595c6 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import MonacoEditor from '@monaco-editor/react'; -import { useAppStore, PostData } from '../../store'; +import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store'; import { showToast } from '../Toast'; import { WysiwygEditor } from '../WysiwygEditor'; import { Lightbox, useMarkdownImages } from '../Lightbox'; import { PostLinks } from '../PostLinks'; +import { ErrorModal } from '../ErrorModal'; import './Editor.css'; // Simple markdown to HTML converter for preview @@ -37,24 +38,36 @@ const markdownToHtml = (markdown: string): string => { .replace(/\n/g, '
'); }; -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; } -const PostEditor: React.FC = ({ post }) => { - const { updatePost } = useAppStore(); +const SavedPostEditor: React.FC = ({ post }) => { + const { + updatePost, + markDirty, + markClean, + isDirty: checkIsDirty, + preferredEditorMode, + setPreferredEditorMode, + showErrorModal, + } = useAppStore(); + const [title, setTitle] = useState(post.title); const [content, setContent] = useState(post.content); const [tags, setTags] = useState(post.tags.join(', ')); const [categories, setCategories] = useState(post.categories.join(', ')); - const [isDirty, setIsDirty] = useState(false); - const [editorMode, setEditorMode] = useState('wysiwyg'); + const [isSaving, setIsSaving] = useState(false); + const [editorMode, setEditorMode] = useState(preferredEditorMode); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const editorRef = useRef(null); + const isDirty = checkIsDirty(post.id); + // Extract images from content for lightbox const images = useMarkdownImages(content); @@ -64,8 +77,8 @@ const PostEditor: React.FC = ({ post }) => { setContent(post.content); setTags(post.tags.join(', ')); setCategories(post.categories.join(', ')); - setIsDirty(false); - }, [post.id]); + markClean(post.id); + }, [post.id, post.title, post.content, post.tags, post.categories, markClean]); // Track changes useEffect(() => { @@ -74,12 +87,24 @@ const PostEditor: React.FC = ({ post }) => { content !== post.content || tags !== post.tags.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 () => { - if (!isDirty) return; + if (!isDirty || isSaving) return; + setIsSaving(true); try { const updated = await window.electronAPI?.posts.update(post.id, { title, @@ -90,14 +115,21 @@ const PostEditor: React.FC = ({ post }) => { if (updated) { updatePost(post.id, updated as Partial); - setIsDirty(false); + markClean(post.id); showToast.success('Post saved'); } } catch (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 () => { await handleSave(); @@ -109,7 +141,12 @@ const PostEditor: React.FC = ({ post }) => { } } catch (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 = ({ post }) => { } } catch (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 = ({ post }) => { showToast.success('Post deleted'); } catch (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 = ({ post }) => {
- {post.title || 'Untitled'} + {title || 'Untitled'} {isDirty && }
@@ -191,8 +238,8 @@ const PostEditor: React.FC = ({ post }) => { Unpublish )} - + +
+ + +
+
+
+ + setTitle(e.target.value)} + placeholder="Enter post title..." + autoFocus + /> +
+
+ + +
+
+
+ + setTags(e.target.value)} + placeholder="tag1, tag2, tag3" + /> +
+
+ + setCategories(e.target.value)} + placeholder="category1, category2" + /> +
+
+
+ +
+
+ +
+ + + +
+ {images.length > 0 && ( + + )} +
+ + {editorMode === 'wysiwyg' && ( + + )} + + {editorMode === 'markdown' && ( + setContent(value || '')} + onMount={handleEditorDidMount} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + fontSize: 14, + fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", + padding: { top: 12, bottom: 12 }, + automaticLayout: true, + scrollBeyondLastLine: false, + renderLineHighlight: 'line', + quickSuggestions: false, + formatOnPaste: true, + cursorStyle: 'line', + cursorBlinking: 'smooth', + }} + /> + )} + + {editorMode === 'preview' && ( +
+ {!content.trim() ? ( +
+

No content to preview

+
+ ) : ( +
+ )} +
+ )} +
+ + {/* Lightbox for viewing images in content */} + setLightboxOpen(false)} + /> +
+ +
+ + New post - not yet saved + + {hasContent && ( + + Press Ctrl+S to save + + )} +
+
+ ); +}; + 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 [alt, setAlt] = useState(item?.alt || ''); @@ -383,9 +738,16 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { }); if (updated) { updateMedia(item.id, updated as Partial); + showToast.success('Media updated'); } } catch (error) { console.error('Failed to update media:', error); + const err = error as Error; + showErrorModal({ + title: 'Update Failed', + message: err.message || 'Failed to update media', + stack: err.stack, + }); } }; @@ -394,8 +756,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { try { await window.electronAPI?.media.delete(item.id); useAppStore.getState().removeMedia(item.id); + showToast.success('Media deleted'); } catch (error) { console.error('Failed to delete media:', error); + const err = error as Error; + showErrorModal({ + title: 'Delete Failed', + message: err.message || 'Failed to delete media', + stack: err.stack, + }); } } }; @@ -488,6 +857,13 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { }; const WelcomeScreen: React.FC = () => { + const { createUnsavedDraft, setSelectedPost } = useAppStore(); + + const handleNewPost = () => { + const draftId = createUnsavedDraft(); + setSelectedPost(draftId); + }; + return (
@@ -498,7 +874,7 @@ const WelcomeScreen: React.FC = () => {

Create a New Post

Start writing your next blog post with Markdown support.

-
@@ -545,18 +921,60 @@ const WelcomeScreen: 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 = () => ( + + ); 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 ( + <> + + {renderErrorModal()} + + ); + } + } + + // Otherwise, it's a saved post const post = posts.find(p => p.id === selectedPostId); if (post) { - return ; + return ( + <> + + {renderErrorModal()} + + ); } } if (activeView === 'media' && selectedMediaId) { - return ; + return ( + <> + + {renderErrorModal()} + + ); } - return ; + return ( + <> + + {renderErrorModal()} + + ); }; diff --git a/src/renderer/components/ErrorModal/ErrorModal.css b/src/renderer/components/ErrorModal/ErrorModal.css new file mode 100644 index 0000000..8f234a1 --- /dev/null +++ b/src/renderer/components/ErrorModal/ErrorModal.css @@ -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); +} diff --git a/src/renderer/components/ErrorModal/ErrorModal.tsx b/src/renderer/components/ErrorModal/ErrorModal.tsx new file mode 100644 index 0000000..4979a4e --- /dev/null +++ b/src/renderer/components/ErrorModal/ErrorModal.tsx @@ -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 = ({ 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 ( +
+
+
+

{error.title || 'Error'}

+ +
+
+
{error.message}
+ {error.stack && ( +
+
+ Stack Trace + +
+
{error.stack}
+
+ )} +
+
+ +
+
+
+ ); +}; diff --git a/src/renderer/components/ErrorModal/index.ts b/src/renderer/components/ErrorModal/index.ts new file mode 100644 index 0000000..bfb6c74 --- /dev/null +++ b/src/renderer/components/ErrorModal/index.ts @@ -0,0 +1,2 @@ +export { ErrorModal } from './ErrorModal'; +export type { ErrorDetails } from './ErrorModal'; diff --git a/src/renderer/components/Sidebar/Sidebar.css b/src/renderer/components/Sidebar/Sidebar.css index d92f20f..7559ea1 100644 --- a/src/renderer/components/Sidebar/Sidebar.css +++ b/src/renderer/components/Sidebar/Sidebar.css @@ -495,3 +495,28 @@ .filter-status button:hover { 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; +} diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 6fd101d..c01f1ad 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useAppStore, PostData } from '../../store'; +import { useAppStore, PostData, UnsavedDraft } from '../../store'; import { showToast } from '../Toast'; import './Sidebar.css'; @@ -222,7 +222,7 @@ const SearchBox: React.FC = ({ onSearch }) => { }; const PostsList: React.FC = () => { - const { posts, selectedPostId, setSelectedPost } = useAppStore(); + const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore(); // Filter state const [searchQuery, setSearchQuery] = useState(''); @@ -321,20 +321,11 @@ const PostsList: React.FC = () => { applyFilters(); }, [selectedTags, selectedCategories]); - const handleCreatePost = async () => { - try { - const newPost = await window.electronAPI?.posts.create({ - title: 'Untitled Post', - content: '# New Post\n\nStart writing your content here...', - }); - 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'); - } + const handleCreatePost = () => { + // Create an unsaved draft instead of immediately saving to database + const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState(); + const draftId = createUnsavedDraft(); + selectPost(draftId); }; // Determine which posts to display @@ -414,6 +405,34 @@ const PostsList: React.FC = () => {
)} + {/* Unsaved Drafts Section - always show at top if there are any */} + {unsavedDrafts.length > 0 && ( +
+
+ + Unsaved ({unsavedDrafts.length}) +
+
+ {unsavedDrafts.map((draft: UnsavedDraft) => ( +
setSelectedPost(draft.id)} + > + +
+
+ {draft.title || 'New Post'} + +
+
Not yet saved
+
+
+ ))} +
+
+ )} + {groupedPosts.draft.length > 0 && (
diff --git a/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx b/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx index ec94629..4353731 100644 --- a/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx +++ b/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx @@ -1,5 +1,7 @@ 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 Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 374edae..bdbc123 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -11,3 +11,4 @@ export { TaskPopup } from './TaskPopup'; export { ResizablePanel } from './ResizablePanel'; export { CredentialsPanel } from './CredentialsPanel'; export { PostLinks } from './PostLinks'; +export { ErrorModal, type ErrorDetails } from './ErrorModal'; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index f54f32e..f45f70b 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -30,6 +30,17 @@ export interface PostData { categories: string[]; } +// Unsaved draft that only exists in memory/local storage until saved +export interface UnsavedDraft { + id: string; // Temporary ID (prefixed with 'draft-') + title: string; + content: string; + tags: string[]; + categories: string[]; + createdAt: string; + isNew: true; // Always true for unsaved drafts +} + export interface MediaData { id: string; filename: string; @@ -55,6 +66,14 @@ export interface TaskProgress { error?: string; } +export interface ErrorDetails { + message: string; + title?: string; + stack?: string; +} + +export type EditorMode = 'wysiwyg' | 'markdown' | 'preview'; + // App State Store interface AppState { // Projects @@ -67,12 +86,21 @@ interface AppState { panelVisible: boolean; selectedPostId: string | null; selectedMediaId: string | null; + preferredEditorMode: EditorMode; // Data posts: PostData[]; media: MediaData[]; tasks: TaskProgress[]; + // Unsaved drafts (memory only until saved) + unsavedDrafts: UnsavedDraft[]; + // Track which posts have unsaved changes (by post ID or draft ID) + dirtyPosts: Set; + + // Error modal + errorModal: ErrorDetails | null; + // Sync syncStatus: 'idle' | 'syncing' | 'error'; syncConfigured: boolean; @@ -95,12 +123,28 @@ interface AppState { togglePanel: () => void; setSelectedPost: (id: string | null) => void; setSelectedMedia: (id: string | null) => void; + setPreferredEditorMode: (mode: EditorMode) => void; setPosts: (posts: PostData[]) => void; addPost: (post: PostData) => void; updatePost: (id: string, post: Partial) => void; removePost: (id: string) => void; + // Unsaved draft actions + createUnsavedDraft: () => string; // Returns the draft ID + updateUnsavedDraft: (id: string, data: Partial) => 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; addMedia: (media: MediaData) => void; updateMedia: (id: string, media: Partial) => void; @@ -119,7 +163,7 @@ interface AppState { export const useAppStore = create()( persist( - (set) => ({ + (set, get) => ({ // Initial Project State projects: [], activeProject: null, @@ -130,12 +174,20 @@ export const useAppStore = create()( panelVisible: false, selectedPostId: null, selectedMediaId: null, + preferredEditorMode: 'wysiwyg', // Initial Data posts: [], media: [], tasks: [], + // Unsaved drafts + unsavedDrafts: [], + dirtyPosts: new Set(), + + // Error modal + errorModal: null, + // Initial Sync State syncStatus: 'idle', syncConfigured: false, @@ -162,6 +214,7 @@ export const useAppStore = create()( togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })), setSelectedPost: (id) => set({ selectedPostId: id }), setSelectedMedia: (id) => set({ selectedMediaId: id }), + setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }), // Post Actions setPosts: (posts) => set({ posts }), @@ -174,6 +227,61 @@ export const useAppStore = create()( 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 setMedia: (media) => set({ media }), addMedia: (media) => set((state) => ({ media: [...state.media, media] })), @@ -209,7 +317,21 @@ export const useAppStore = create()( panelVisible: state.panelVisible, selectedPostId: state.selectedPostId, selectedMediaId: state.selectedMediaId, + preferredEditorMode: state.preferredEditorMode, + // Persist unsaved drafts for recovery + unsavedDrafts: state.unsavedDrafts, + // Convert Set to array for storage + dirtyPosts: [...state.dirtyPosts], }), + // Merge function to restore Set from array + merge: (persisted, current) => { + const persistedState = persisted as Partial & { dirtyPosts?: string[] }; + return { + ...current, + ...persistedState, + dirtyPosts: new Set(persistedState.dirtyPosts || []), + }; + }, } ) ); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 5dd6d02..5b766ee 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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';