feat: more cleanup work in UI

This commit is contained in:
2026-02-10 15:24:36 +01:00
parent 46970de656
commit 0a6710b684
22 changed files with 1945 additions and 461 deletions

View File

@@ -8,6 +8,10 @@ sync to a cloud system for syncing data and also rendering the full blog.
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
We need a good way to handle the syncing of the non-metadata components (posts and media files), because that
is not part of the database sync. One way could be using something like dropbox in the background, so that
the posts/ and media/ folders are automatically synced to some area in dropbox and transported that way.
Blog post metadata should be managed in the SQLite database in the user local folder, so it persists application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a markdown file with a properties segment in the top of the file with YAML like property definitions, so all metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts.
The application must be able to support multiple projects (ie web sites), so there must be a way to create
@@ -23,6 +27,35 @@ Integrate toasts as notification mechanism that will be used whenever anything h
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level.
## Posting life-cycle
New posts start in draft state. Drafts are automatically saved in the background, but the draft content is
not directly part of the publishing pipeline. A user can discard a draft, which either deltes a new post
or reverts a post that is edited to the last published state.
A user can publish a post, that moves the content from the draft state to the published state. Also a user
can start editing in a published post, that moves it to draft state with a copy of the original post as
the starting point.
So essentially posts have two content fields, one for draft and one for published. Editing only happens on
the draft state. Undo/Redo is also only available on the draft state editing session in the text field
with standard mechanisms.
published posts have a delete button that allows deleting a post that exists. This will internally switch
the post to deleted, and filter it out, but will not remove it fully from the database, because the publishing
pipeline later might need the fact of the deletion as information source to do its thing (update relevant
files).
So a post starts in "draft" and can go to "published" and from there to "deleted". From "draft" it can only
go back to "published" (via discard) or vanish fully from the database (because drafts for new posts are
not relevant for the pipeline, as they never were published before).
Posts in draft are automatically saved during edit every 20 seconds and a dot in the tab title gives
information about its state as unsaved. The user can force the save with the standard hotkey for that
purpose or just wait. Switching to another post will also save a draft automatically.
Published content is only ever updated when the publish action is done by the user.
## UI and UX specifics
The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the

999
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,15 @@
"description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js",
"scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch",
"dev:renderer": "node ./node_modules/vite/bin/vite.js",
"dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json",
"build:renderer": "node ./node_modules/vite/bin/vite.js build",
"start": "node ./node_modules/electron/cli.js .",
"start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
@@ -23,6 +25,9 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
@@ -31,14 +36,17 @@
"@vitest/coverage-v8": "^1.0.0",
"@vitest/ui": "^1.0.0",
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"drizzle-kit": "^0.20.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"jsdom": "^28.0.0",
"memfs": "^4.6.0",
"tsx": "^4.6.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^1.0.0"
"vitest": "^1.0.0",
"wait-on": "^9.0.3"
},
"dependencies": {
"@floating-ui/dom": "^1.7.5",

View File

@@ -149,7 +149,12 @@ export class DatabaseConnection {
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT
categories TEXT,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
);
CREATE TABLE IF NOT EXISTS media (
@@ -242,6 +247,18 @@ export class DatabaseConnection {
);
}
// Migration: Add published snapshot columns for discard functionality
const publishedContentCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('posts') WHERE name = 'published_content'"
);
if (publishedContentCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_title TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_content TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_tags TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_categories TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT");
}
// Create FTS5 virtual table for full-text search
await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(

View File

@@ -29,6 +29,12 @@ export const posts = sqliteTable('posts', {
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
// Published snapshot - stores the last published version for discard functionality
publishedTitle: text('published_title'),
publishedContent: text('published_content'),
publishedTags: text('published_tags'), // JSON array stored as text
publishedCategories: text('published_categories'), // JSON array stored as text
publishedExcerpt: text('published_excerpt'),
});
// Media table - stores metadata for images and other media

View File

@@ -112,6 +112,52 @@ export class PostEngine extends EventEmitter {
.replace(/^-|-$/g, '');
}
/**
* Check if a slug is available (not used by any existing post)
* @param slug The slug to check
* @param excludePostId Optional post ID to exclude (for updates)
*/
async isSlugAvailable(slug: string, excludePostId?: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db
.select({ id: posts.id })
.from(posts)
.where(and(
eq(posts.slug, slug),
eq(posts.projectId, this.currentProjectId)
))
.get();
if (!existing) return true;
if (excludePostId && existing.id === excludePostId) return true;
return false;
}
/**
* Generate a unique slug based on a title
* If the slug already exists, appends -2, -3, etc.
*/
async generateUniqueSlug(title: string, excludePostId?: string): Promise<string> {
const baseSlug = this.generateSlug(title || 'untitled');
if (await this.isSlugAvailable(baseSlug, excludePostId)) {
return baseSlug;
}
// Find next available number
let counter = 2;
while (counter < 1000) {
const candidateSlug = `${baseSlug}-${counter}`;
if (await this.isSlugAvailable(candidateSlug, excludePostId)) {
return candidateSlug;
}
counter++;
}
// Fallback: add timestamp
return `${baseSlug}-${Date.now()}`;
}
private calculateChecksum(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
@@ -177,7 +223,11 @@ export class PostEngine extends EventEmitter {
const client = getDatabase().getLocalClient();
const now = new Date();
const id = uuidv4();
const slug = data.slug || this.generateSlug(data.title || 'untitled');
// Use provided slug or generate a unique one from title
const slug = data.slug
? (await this.isSlugAvailable(data.slug) ? data.slug : await this.generateUniqueSlug(data.title || 'untitled'))
: await this.generateUniqueSlug(data.title || 'untitled');
const post: PostData = {
id,
@@ -539,10 +589,58 @@ export class PostEngine extends EventEmitter {
}
async publishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
const db = getDatabase().getLocal();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
// First update the post with published status
const result = await this.updatePost(id, {
status: 'published',
publishedAt: new Date(),
});
if (result) {
// Save the published snapshot for discard functionality
await db.update(posts)
.set({
publishedTitle: result.title,
publishedContent: result.content,
publishedExcerpt: result.excerpt,
publishedTags: JSON.stringify(result.tags),
publishedCategories: JSON.stringify(result.categories),
})
.where(eq(posts.id, id));
}
return result;
}
async discardChanges(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost || !dbPost.publishedContent) {
// No published version to revert to
return null;
}
// Revert to the published snapshot
return this.updatePost(id, {
title: dbPost.publishedTitle || dbPost.title,
content: dbPost.publishedContent,
excerpt: dbPost.publishedExcerpt || undefined,
tags: JSON.parse(dbPost.publishedTags || '[]'),
categories: JSON.parse(dbPost.publishedCategories || '[]'),
});
}
async hasPublishedVersion(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
return !!(dbPost && dbPost.publishedContent);
}
async unpublishPost(id: string): Promise<PostData | null> {

View File

@@ -63,6 +63,16 @@ export function registerIpcHandlers(): void {
return engine.createPost(data);
});
ipcMain.handle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => {
const engine = getPostEngine();
return engine.isSlugAvailable(slug, excludePostId);
});
ipcMain.handle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => {
const engine = getPostEngine();
return engine.generateUniqueSlug(title, excludePostId);
});
ipcMain.handle('posts:update', async (_, id: string, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.updatePost(id, data);
@@ -98,6 +108,16 @@ export function registerIpcHandlers(): void {
return engine.unpublishPost(id);
});
ipcMain.handle('posts:discard', async (_, id: string) => {
const engine = getPostEngine();
return engine.discardChanges(id);
});
ipcMain.handle('posts:hasPublishedVersion', async (_, id: string) => {
const engine = getPostEngine();
return engine.hasPublishedVersion(id);
});
ipcMain.handle('posts:rebuildFromFiles', async () => {
const engine = getPostEngine();
return engine.rebuildDatabaseFromFiles();

View File

@@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
discard: (id: string) => ipcRenderer.invoke('posts:discard', id),
hasPublishedVersion: (id: string) => ipcRenderer.invoke('posts:hasPublishedVersion', id),
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
search: (query: string) => ipcRenderer.invoke('posts:search', query),
filter: (filter: unknown) => ipcRenderer.invoke('posts:filter', filter),
@@ -33,6 +35,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId),
generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId),
},
// Media

View File

@@ -100,6 +100,12 @@
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
.auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.editor-content {
flex: 1;
display: flex;

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import MonacoEditor from '@monaco-editor/react';
import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store';
import { useAppStore, PostData, EditorMode } from '../../store';
import { showToast } from '../Toast';
import { WysiwygEditor } from '../WysiwygEditor';
import { Lightbox, useMarkdownImages } from '../Lightbox';
@@ -38,14 +38,11 @@ const markdownToHtml = (markdown: string): string => {
.replace(/\n/g, '<br />');
};
// Check if an ID is for an unsaved draft
const isUnsavedDraftId = (id: string): boolean => id.startsWith('draft-');
interface SavedPostEditorProps {
interface PostEditorProps {
post: PostData;
}
const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const {
updatePost,
markDirty,
@@ -61,6 +58,7 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
const [tags, setTags] = useState(post.tags.join(', '));
const [categories, setCategories] = useState(post.categories.join(', '));
const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
@@ -68,10 +66,62 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
const isDirty = checkIsDirty(post.id);
// Check if post has a published version for discard functionality
useEffect(() => {
window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion);
}, [post.id]);
// Extract images from content for lightbox
const images = useMarkdownImages(content);
// Reset when post changes
// Track latest values for auto-save on unmount/switch
const pendingChangesRef = useRef<{
title: string;
content: string;
tags: string;
categories: string;
postId: string;
isDirty: boolean;
} | null>(null);
// Update ref when values change
useEffect(() => {
pendingChangesRef.current = {
title,
content,
tags,
categories,
postId: post.id,
isDirty,
};
}, [title, content, tags, categories, post.id, isDirty]);
// Auto-save when switching away from a post or unmounting
useEffect(() => {
const prevPostId = post.id;
return () => {
const pending = pendingChangesRef.current;
if (pending && pending.postId === prevPostId && pending.isDirty) {
// Fire and forget auto-save
window.electronAPI?.posts.update(pending.postId, {
title: pending.title,
content: pending.content,
tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: pending.categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
}).then((updated) => {
if (updated) {
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
useAppStore.getState().markClean(pending.postId);
}
}).catch((error) => {
console.error('Auto-save failed:', error);
});
}
};
}, [post.id]);
// Reset when post changes (after auto-save cleanup runs)
useEffect(() => {
setTitle(post.title);
setContent(post.content);
@@ -168,6 +218,48 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
}
};
const handleDiscard = async () => {
// If this post has a published version, revert to it
// If never published, delete the post entirely
const confirmMessage = hasPublishedVersion
? 'Discard all changes since last publish? This cannot be undone.'
: 'Delete this draft? This cannot be undone.';
if (!confirm(confirmMessage)) {
return;
}
try {
if (hasPublishedVersion) {
// Revert to published version
const reverted = await window.electronAPI?.posts.discard(post.id);
if (reverted) {
setTitle(reverted.title);
setContent(reverted.content);
setTags(reverted.tags.join(', '));
setCategories(reverted.categories.join(', '));
updatePost(post.id, reverted as Partial<PostData>);
markClean(post.id);
showToast.success('Reverted to last published version');
}
} else {
// Never published - delete the post entirely
await window.electronAPI?.posts.delete(post.id);
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Draft deleted');
}
} catch (error) {
console.error('Failed to discard/delete:', error);
const err = error as Error;
showErrorModal({
title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed',
message: err.message || 'Operation failed',
stack: err.stack,
});
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
@@ -224,24 +316,41 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
<div className="editor-tabs">
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
<span className="editor-tab-title">{title || 'Untitled'}</span>
{isDirty && <span className="editor-tab-dirty"></span>}
{isDirty && <span className="editor-tab-dirty" title="Unsaved changes (auto-saves on switch)"></span>}
</div>
</div>
<div className="editor-actions">
<span className={`status-badge status-${post.status}`}>
{post.status}
</span>
{isSaving && <span className="auto-save-indicator">Saving...</span>}
{post.status === 'draft' ? (
<button onClick={handlePublish} title="Publish">Publish</button>
<button
onClick={handlePublish}
className="success"
title="Save and make this post public"
>
Publish
</button>
) : (
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
<button
onClick={handleUnpublish}
className="secondary"
title="Return to draft status"
>
Unpublish
</button>
)}
<button onClick={handleSave} disabled={!isDirty || isSaving} title="Save (Ctrl+S)">
{isSaving ? 'Saving...' : 'Save'}
{hasPublishedVersion && (
<button
onClick={handleDiscard}
className="secondary"
title="Revert to last published version"
>
Discard Changes
</button>
<button onClick={handleDelete} className="secondary danger" title="Delete">
)}
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button>
</div>
@@ -401,314 +510,6 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
);
};
interface UnsavedDraftEditorProps {
draft: UnsavedDraft;
}
const UnsavedDraftEditor: React.FC<UnsavedDraftEditorProps> = ({ draft }) => {
const {
updateUnsavedDraft,
removeUnsavedDraft,
addPost,
setSelectedPost,
preferredEditorMode,
setPreferredEditorMode,
showErrorModal,
markClean,
} = useAppStore();
const [title, setTitle] = useState(draft.title);
const [content, setContent] = useState(draft.content);
const [tags, setTags] = useState(draft.tags.join(', '));
const [categories, setCategories] = useState(draft.categories.join(', '));
const [isSaving, setIsSaving] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const editorRef = useRef<unknown>(null);
// Extract images from content for lightbox
const images = useMarkdownImages(content);
// Update draft in store when local state changes (for recovery purposes)
useEffect(() => {
const timeout = setTimeout(() => {
updateUnsavedDraft(draft.id, {
title,
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
});
}, 500); // Debounce updates
return () => clearTimeout(timeout);
}, [title, content, tags, categories, draft.id, updateUnsavedDraft]);
// Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => {
setEditorMode(mode);
setPreferredEditorMode(mode);
};
const handleSave = useCallback(async () => {
if (isSaving) return;
// Validate - need at least a title
if (!title.trim()) {
showErrorModal({
title: 'Validation Error',
message: 'Please enter a title for your post before saving.',
});
return;
}
setIsSaving(true);
try {
// Create the post in the database
const newPost = await window.electronAPI?.posts.create({
title: title.trim(),
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
});
if (newPost) {
const postData = newPost as PostData;
// Add to posts list
addPost(postData);
// Remove the unsaved draft
removeUnsavedDraft(draft.id);
// Select the new post
setSelectedPost(postData.id);
markClean(postData.id);
showToast.success('Post saved');
}
} catch (error) {
console.error('Failed to save post:', error);
const err = error as Error;
showErrorModal({
title: 'Save Failed',
message: err.message || 'Failed to save post',
stack: err.stack,
});
} finally {
setIsSaving(false);
}
}, [title, content, tags, categories, isSaving, draft.id, addPost, removeUnsavedDraft, setSelectedPost, markClean, showErrorModal]);
const handleDiscard = () => {
if (title.trim() || content.trim()) {
if (!confirm('Are you sure you want to discard this unsaved post?')) {
return;
}
}
removeUnsavedDraft(draft.id);
setSelectedPost(null);
};
// Handle Monaco editor mount
const handleEditorDidMount = (editor: unknown) => {
editorRef.current = editor;
};
// Save on Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave]);
// Listen for menu events
useEffect(() => {
const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave);
return () => {
unsubscribeSave?.();
};
}, [handleSave]);
const hasContent = title.trim() || content.trim();
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active dirty">
<span className="editor-tab-title">{title || 'New Post'}</span>
<span className="editor-tab-dirty"></span>
<span className="editor-tab-badge new">NEW</span>
</div>
</div>
<div className="editor-actions">
<span className="status-badge status-unsaved">unsaved</span>
<button onClick={handleSave} disabled={isSaving} title="Save (Ctrl+S)">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button onClick={handleDiscard} className="secondary danger" title="Discard">
Discard
</button>
</div>
</div>
<div className="editor-content">
<div className="editor-meta">
<div className="editor-field">
<label>Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter post title..."
autoFocus
/>
</div>
<div className="editor-field slug-preview">
<label>Slug (auto-generated on save)</label>
<input
type="text"
value={title ? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') : ''}
disabled
className="disabled"
placeholder="will-be-generated-from-title"
/>
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</div>
<div className="editor-field">
<label>Categories (comma-separated)</label>
<input
type="text"
value={categories}
onChange={(e) => setCategories(e.target.value)}
placeholder="category1, category2"
/>
</div>
</div>
</div>
<div className="editor-body">
<div className="editor-toolbar">
<label>Content</label>
<div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => handleEditorModeChange('wysiwyg')}
title="Visual editor"
>
Visual
</button>
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => handleEditorModeChange('markdown')}
title="Markdown source"
>
Markdown
</button>
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => handleEditorModeChange('preview')}
title="Read-only preview"
>
Preview
</button>
</div>
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
</div>
{editorMode === 'wysiwyg' && (
<WysiwygEditor
content={content}
onChange={setContent}
placeholder="Start writing your post..."
/>
)}
{editorMode === 'markdown' && (
<MonacoEditor
height="100%"
defaultLanguage="markdown"
value={content}
onChange={(value) => setContent(value || '')}
onMount={handleEditorDidMount}
theme="vs-dark"
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
automaticLayout: true,
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
quickSuggestions: false,
formatOnPaste: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
}}
/>
)}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body">
{!content.trim() ? (
<div className="preview-empty">
<p className="text-muted">No content to preview</p>
</div>
) : (
<div
className="preview-content"
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
/>
)}
</div>
)}
</div>
{/* Lightbox for viewing images in content */}
<Lightbox
images={images}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</div>
<div className="editor-footer">
<span className="text-muted text-small">
New post - not yet saved
</span>
{hasContent && (
<span className="text-muted text-small">
Press Ctrl+S to save
</span>
)}
</div>
</div>
);
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia, showErrorModal } = useAppStore();
const item = media.find(m => m.id === mediaId);
@@ -857,11 +658,23 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
};
const WelcomeScreen: React.FC = () => {
const { createUnsavedDraft, setSelectedPost } = useAppStore();
const { addPost, setSelectedPost } = useAppStore();
const handleNewPost = () => {
const draftId = createUnsavedDraft();
setSelectedPost(draftId);
const handleNewPost = async () => {
try {
const newPost = await window.electronAPI?.posts.create({
title: 'Untitled',
content: '',
tags: [],
categories: [],
});
if (newPost) {
addPost(newPost as PostData);
setSelectedPost(newPost.id);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
return (
@@ -926,9 +739,10 @@ export const Editor: React.FC = () => {
selectedPostId,
selectedMediaId,
posts,
unsavedDrafts,
errorModal,
hideErrorModal,
isLoading,
setSelectedPost,
} = useAppStore();
// Show error modal if present
@@ -937,29 +751,32 @@ export const Editor: React.FC = () => {
);
if (activeView === 'posts' && selectedPostId) {
// Check if it's an unsaved draft
if (isUnsavedDraftId(selectedPostId)) {
const draft = unsavedDrafts.find(d => d.id === selectedPostId);
if (draft) {
return (
<>
<UnsavedDraftEditor draft={draft} />
{renderErrorModal()}
</>
);
}
}
// Otherwise, it's a saved post
const post = posts.find(p => p.id === selectedPostId);
if (post) {
return (
<>
<SavedPostEditor post={post} />
<PostEditor post={post} />
{renderErrorModal()}
</>
);
}
// Post not found - show loading if still loading, otherwise clear selection
if (isLoading) {
return (
<>
<div className="editor-empty">
<div className="welcome-content">
<p className="text-muted">Loading post...</p>
</div>
</div>
{renderErrorModal()}
</>
);
}
// Post truly not found - clear selection and fall through to welcome screen
setSelectedPost(null);
}
if (activeView === 'media' && selectedMediaId) {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useAppStore, PostData, UnsavedDraft } from '../../store';
import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast';
import './Sidebar.css';
@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore();
const { posts, selectedPostId, setSelectedPost } = useAppStore();
// Filter state
const [searchQuery, setSearchQuery] = useState('');
@@ -321,11 +321,23 @@ const PostsList: React.FC = () => {
applyFilters();
}, [selectedTags, selectedCategories]);
const handleCreatePost = () => {
// Create an unsaved draft instead of immediately saving to database
const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState();
const draftId = createUnsavedDraft();
selectPost(draftId);
const handleCreatePost = async () => {
// Create a real post immediately in the database with default empty content
try {
const { addPost, setSelectedPost: selectPost } = useAppStore.getState();
const newPost = await window.electronAPI?.posts.create({
title: 'Untitled',
content: '',
tags: [],
categories: [],
});
if (newPost) {
addPost(newPost as PostData);
selectPost(newPost.id);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
// Determine which posts to display
@@ -405,34 +417,6 @@ const PostsList: React.FC = () => {
</div>
)}
{/* Unsaved Drafts Section - always show at top if there are any */}
{unsavedDrafts.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-unsaved"></span>
Unsaved ({unsavedDrafts.length})
</div>
<div className="sidebar-list">
{unsavedDrafts.map((draft: UnsavedDraft) => (
<div
key={draft.id}
className={`sidebar-item post-type-new unsaved ${selectedPostId === draft.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(draft.id)}
>
<span className="post-type-icon" title="New post"></span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">
{draft.title || 'New Post'}
<span className="unsaved-indicator"></span>
</div>
<div className="sidebar-item-meta">Not yet saved</div>
</div>
</div>
))}
</div>
</div>
)}
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">

View File

@@ -1,12 +1,11 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Underline from '@tiptap/extension-underline';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import TurndownService from 'turndown';
import './WysiwygEditor.css';
@@ -88,6 +87,10 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
onChange,
placeholder = 'Start writing your content...',
}) => {
// Track if we're updating from internal changes vs external prop changes
const isInternalChange = useRef(false);
const lastExternalContent = useRef(content);
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -101,34 +104,38 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
class: 'editor-link',
},
}),
Underline,
Image.configure({
HTMLAttributes: {
class: 'editor-image',
},
}),
Underline,
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
onUpdate: ({ editor }) => {
isInternalChange.current = true;
const html = editor.getHTML();
const markdown = turndownService.turndown(html);
onChange(markdown);
},
editable: true,
});
// Sync content from external changes only (e.g., post switch)
useEffect(() => {
if (editor && content) {
const currentHtml = editor.getHTML();
if (editor && content !== lastExternalContent.current) {
// This is an external change (e.g., switching posts)
if (!isInternalChange.current) {
const newHtml = markdownToHtml(content);
// Only update if content is significantly different
if (turndownService.turndown(currentHtml) !== content) {
editor.commands.setContent(newHtml);
}
lastExternalContent.current = content;
isInternalChange.current = false;
}
}, [content]);
}, [content, editor]);
const addImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
@@ -161,7 +168,7 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
<div className="wysiwyg-editor">
{/* Bubble menu appears when text is selected */}
{editor && (
<BubbleMenu className="bubble-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<BubbleMenu className="bubble-menu" editor={editor}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
@@ -206,7 +213,7 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
{/* Floating menu appears on empty lines */}
{editor && (
<FloatingMenu className="floating-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<FloatingMenu className="floating-menu" editor={editor}>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob:; worker-src 'self' blob:; font-src 'self' data:;" />
<title>Blogging Desktop Server</title>
</head>
<body>

View File

@@ -1,8 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import App from './App';
import './styles/global.css';
// Configure Monaco to use local bundled version instead of CDN
// This avoids CSP issues in Electron
loader.config({ monaco });
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -30,17 +30,6 @@ export interface PostData {
categories: string[];
}
// Unsaved draft that only exists in memory/local storage until saved
export interface UnsavedDraft {
id: string; // Temporary ID (prefixed with 'draft-')
title: string;
content: string;
tags: string[];
categories: string[];
createdAt: string;
isNew: true; // Always true for unsaved drafts
}
export interface MediaData {
id: string;
filename: string;
@@ -93,9 +82,7 @@ interface AppState {
media: MediaData[];
tasks: TaskProgress[];
// Unsaved drafts (memory only until saved)
unsavedDrafts: UnsavedDraft[];
// Track which posts have unsaved changes (by post ID or draft ID)
// Track which posts have unsaved changes (by post ID)
dirtyPosts: Set<string>;
// Error modal
@@ -130,12 +117,6 @@ interface AppState {
updatePost: (id: string, post: Partial<PostData>) => void;
removePost: (id: string) => void;
// Unsaved draft actions
createUnsavedDraft: () => string; // Returns the draft ID
updateUnsavedDraft: (id: string, data: Partial<UnsavedDraft>) => void;
removeUnsavedDraft: (id: string) => void;
getUnsavedDraft: (id: string) => UnsavedDraft | undefined;
// Dirty tracking
markDirty: (id: string) => void;
markClean: (id: string) => void;
@@ -181,8 +162,7 @@ export const useAppStore = create<AppState>()(
media: [],
tasks: [],
// Unsaved drafts
unsavedDrafts: [],
// Dirty posts tracking
dirtyPosts: new Set<string>(),
// Error modal
@@ -222,49 +202,16 @@ export const useAppStore = create<AppState>()(
updatePost: (id, updatedPost) => set((state) => ({
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
})),
removePost: (id) => set((state) => ({
posts: state.posts.filter((p) => p.id !== id),
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
})),
// Unsaved draft actions
createUnsavedDraft: () => {
const id = `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const draft: UnsavedDraft = {
id,
title: '',
content: '',
tags: [],
categories: [],
createdAt: new Date().toISOString(),
isNew: true,
};
set((state) => ({
unsavedDrafts: [...state.unsavedDrafts, draft],
dirtyPosts: new Set([...state.dirtyPosts, id]),
}));
return id;
},
updateUnsavedDraft: (id, data) => set((state) => ({
unsavedDrafts: state.unsavedDrafts.map((d) =>
d.id === id ? { ...d, ...data } : d
),
dirtyPosts: new Set([...state.dirtyPosts, id]),
})),
removeUnsavedDraft: (id) => set((state) => {
removePost: (id) => set((state) => {
const newDirtyPosts = new Set(state.dirtyPosts);
newDirtyPosts.delete(id);
return {
unsavedDrafts: state.unsavedDrafts.filter((d) => d.id !== id),
posts: state.posts.filter((p) => p.id !== id),
dirtyPosts: newDirtyPosts,
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
};
}),
getUnsavedDraft: (id) => get().unsavedDrafts.find((d) => d.id === id),
// Dirty tracking
markDirty: (id) => set((state) => ({
dirtyPosts: new Set([...state.dirtyPosts, id]),
@@ -318,8 +265,6 @@ export const useAppStore = create<AppState>()(
selectedPostId: state.selectedPostId,
selectedMediaId: state.selectedMediaId,
preferredEditorMode: state.preferredEditorMode,
// Persist unsaved drafts for recovery
unsavedDrafts: state.unsavedDrafts,
// Convert Set to array for storage
dirtyPosts: [...state.dirtyPosts],
}),

View File

@@ -4,7 +4,6 @@ export {
type PostData,
type MediaData,
type TaskProgress,
type UnsavedDraft,
type EditorMode,
type ErrorDetails
} from './appStore';

View File

@@ -164,6 +164,31 @@ button.secondary:hover {
background-color: #4a4d51;
}
button.primary {
background-color: var(--vscode-button-background);
font-weight: 500;
}
button.primary:hover {
background-color: var(--vscode-button-hoverBackground);
}
button.success {
background-color: #28a745;
}
button.success:hover {
background-color: #218838;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@@ -104,12 +104,19 @@ export interface ElectronAPI {
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
discard: (id: string) => Promise<PostData | null>;
hasPublishedVersion: (id: string) => Promise<boolean>;
rebuildFromFiles: () => Promise<void>;
search: (query: string) => Promise<SearchResult[]>;
filter: (filter: PostFilter) => Promise<PostData[]>;
getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getLinksTo: (id: string) => Promise<PostData[]>;
getLinkedBy: (id: string) => Promise<PostData[]>;
rebuildLinks: () => Promise<void>;
isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>;
generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>;
};
media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;

View File

@@ -0,0 +1,252 @@
/**
* Tests for PostEditor behavior
* Validates saving, dirty tracking, and post switching scenarios
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAppStore, PostData } from '../../../src/renderer/store/appStore';
// Helper to create a mock post
const createMockPost = (overrides: Partial<PostData> = {}): PostData => ({
id: `post-${Date.now()}-${Math.random().toString(36).substring(7)}`,
title: 'Test Post',
slug: 'test-post',
content: '# Test Content',
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: [],
...overrides,
});
// Direct store access
const getStore = () => useAppStore.getState();
const setState = useAppStore.setState;
// Mock window.electronAPI
const mockElectronAPI = {
posts: {
update: vi.fn(),
publish: vi.fn(),
create: vi.fn(),
},
};
describe('PostEditor Behavior', () => {
beforeEach(() => {
// Reset store state
setState({
posts: [],
selectedPostId: null,
dirtyPosts: new Set(),
});
// Reset mocks
vi.clearAllMocks();
// Set up window.electronAPI mock
(globalThis as any).window = {
electronAPI: mockElectronAPI,
};
});
describe('Post Editing Flow', () => {
it('should update store when save returns successfully', async () => {
const originalPost = createMockPost({
id: 'post-1',
title: 'Original Title',
content: 'Original content',
});
// Add post to store
getStore().addPost(originalPost);
// Simulate the save response from the backend
const updatedPost = {
...originalPost,
title: 'Updated Title',
content: 'Updated content',
updatedAt: new Date().toISOString(),
};
mockElectronAPI.posts.update.mockResolvedValue(updatedPost);
// Simulate the component's save flow
const result = await mockElectronAPI.posts.update('post-1', {
title: 'Updated Title',
content: 'Updated content',
});
if (result) {
getStore().updatePost('post-1', result);
getStore().markClean('post-1');
}
// Verify store was updated
const storePost = getStore().posts.find(p => p.id === 'post-1');
expect(storePost?.title).toBe('Updated Title');
expect(storePost?.content).toBe('Updated content');
expect(getStore().isDirty('post-1')).toBe(false);
});
it('should NOT clear store data when save returns undefined', async () => {
const originalPost = createMockPost({
id: 'post-1',
title: 'Original Title',
content: 'Original content',
});
// Add post to store
getStore().addPost(originalPost);
// Simulate save returning undefined (API error/issue)
mockElectronAPI.posts.update.mockResolvedValue(undefined);
// Simulate the component's save flow
const result = await mockElectronAPI.posts.update('post-1', {
title: 'Updated Title',
content: 'Updated content',
});
// Following the component's pattern: only update if result is truthy
if (result) {
getStore().updatePost('post-1', result);
getStore().markClean('post-1');
}
// Verify store was NOT corrupted
const storePost = getStore().posts.find(p => p.id === 'post-1');
expect(storePost?.title).toBe('Original Title');
expect(storePost?.content).toBe('Original content');
});
it('should persist changes when switching between posts', async () => {
const post1 = createMockPost({ id: 'post-1', title: 'Post 1' });
const post2 = createMockPost({ id: 'post-2', title: 'Post 2' });
// Add posts to store
getStore().addPost(post1);
getStore().addPost(post2);
// Simulate editing and saving post 1
const updatedPost1 = { ...post1, title: 'Post 1 Updated' };
mockElectronAPI.posts.update.mockResolvedValue(updatedPost1);
const result = await mockElectronAPI.posts.update('post-1', { title: 'Post 1 Updated' });
if (result) {
getStore().updatePost('post-1', result);
getStore().markClean('post-1');
}
// Select post 2
getStore().setSelectedPost('post-2');
// Then select post 1 again
getStore().setSelectedPost('post-1');
// Verify post 1 still has the saved changes
const storePost1 = getStore().posts.find(p => p.id === 'post-1');
expect(storePost1?.title).toBe('Post 1 Updated');
});
it('should warn or auto-save when switching posts with unsaved changes', () => {
const post1 = createMockPost({ id: 'post-1', title: 'Post 1' });
const post2 = createMockPost({ id: 'post-2', title: 'Post 2' });
getStore().addPost(post1);
getStore().addPost(post2);
getStore().setSelectedPost('post-1');
// Simulate user editing post 1 (markDirty is called by the component)
getStore().markDirty('post-1');
// ISSUE: When switching to post 2, post 1's local edits are lost
// because they're only in React useState, not persisted anywhere
// The store knows it's dirty, but the actual content changes are gone
expect(getStore().isDirty('post-1')).toBe(true);
// Switch to post 2 without saving
getStore().setSelectedPost('post-2');
// Post 1 is STILL marked dirty (store doesn't auto-clean on switch)
// But this is misleading - the actual edits are lost!
expect(getStore().isDirty('post-1')).toBe(true);
// This test documents the current (buggy) behavior:
// - markDirty tracks that changes exist
// - But the actual changes (in React useState) are lost when switching
//
// FIX NEEDED: Either auto-save, confirm before switching, or store edits
});
it('should track dirty state correctly when content changes', () => {
const post = createMockPost({ id: 'post-1', title: 'Original' });
getStore().addPost(post);
// Fresh post should not be dirty
expect(getStore().isDirty('post-1')).toBe(false);
// Simulating content change
getStore().markDirty('post-1');
expect(getStore().isDirty('post-1')).toBe(true);
// After save
getStore().markClean('post-1');
expect(getStore().isDirty('post-1')).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle save when post no longer exists in store', async () => {
const post = createMockPost({ id: 'post-1' });
getStore().addPost(post);
// Remove post from store (simulating deletion from another source)
getStore().removePost('post-1');
// Attempt to update
getStore().updatePost('post-1', { title: 'Updated' });
// Should not add the post back
expect(getStore().posts).toHaveLength(0);
});
it('should handle rapid consecutive saves', async () => {
const post = createMockPost({ id: 'post-1', title: 'Original' });
getStore().addPost(post);
// First save
mockElectronAPI.posts.update.mockResolvedValueOnce({ ...post, title: 'First Update' });
const result1 = await mockElectronAPI.posts.update('post-1', { title: 'First Update' });
if (result1) getStore().updatePost('post-1', result1);
// Second save (rapid)
mockElectronAPI.posts.update.mockResolvedValueOnce({ ...post, title: 'Second Update' });
const result2 = await mockElectronAPI.posts.update('post-1', { title: 'Second Update' });
if (result2) getStore().updatePost('post-1', result2);
// Should have the last update
const storePost = getStore().posts.find(p => p.id === 'post-1');
expect(storePost?.title).toBe('Second Update');
});
it('should handle publish after save', async () => {
const post = createMockPost({ id: 'post-1', status: 'draft' });
getStore().addPost(post);
// Save first
mockElectronAPI.posts.update.mockResolvedValue({ ...post, title: 'Saved' });
const saveResult = await mockElectronAPI.posts.update('post-1', { title: 'Saved' });
if (saveResult) getStore().updatePost('post-1', saveResult);
// Then publish
mockElectronAPI.posts.publish.mockResolvedValue({ ...post, title: 'Saved', status: 'published' });
const publishResult = await mockElectronAPI.posts.publish('post-1');
if (publishResult) getStore().updatePost('post-1', publishResult);
const storePost = getStore().posts.find(p => p.id === 'post-1');
expect(storePost?.status).toBe('published');
expect(storePost?.title).toBe('Saved');
});
});
});

View File

@@ -0,0 +1,163 @@
/**
* Tests for the app store
* Validates state management behavior for posts, dirty tracking, and UI state
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore, PostData } from '../../../src/renderer/store/appStore';
// Helper to create a mock post
const createMockPost = (overrides: Partial<PostData> = {}): PostData => ({
id: `post-${Date.now()}-${Math.random().toString(36).substring(7)}`,
title: 'Test Post',
slug: 'test-post',
content: '# Test Content',
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: [],
...overrides,
});
// Direct store access without React rendering
const getStore = () => useAppStore.getState();
const setState = useAppStore.setState;
describe('AppStore', () => {
beforeEach(() => {
// Reset store state before each test
setState({
posts: [],
selectedPostId: null,
dirtyPosts: new Set(),
});
});
describe('Post Management', () => {
it('should add a post to the store', () => {
const post = createMockPost({ id: 'post-1', title: 'New Post' });
getStore().addPost(post);
expect(getStore().posts).toHaveLength(1);
expect(getStore().posts[0].title).toBe('New Post');
});
it('should update an existing post in the store', () => {
const post = createMockPost({ id: 'post-1', title: 'Original Title' });
getStore().addPost(post);
getStore().updatePost('post-1', { title: 'Updated Title' });
expect(getStore().posts).toHaveLength(1);
expect(getStore().posts[0].title).toBe('Updated Title');
});
it('should preserve other post fields when updating', () => {
const post = createMockPost({
id: 'post-1',
title: 'Original',
content: 'Original Content',
tags: ['tag1'],
});
getStore().addPost(post);
getStore().updatePost('post-1', { title: 'Updated Title' });
expect(getStore().posts[0].content).toBe('Original Content');
expect(getStore().posts[0].tags).toEqual(['tag1']);
});
it('should remove a post from the store', () => {
const post = createMockPost({ id: 'post-1' });
getStore().addPost(post);
getStore().removePost('post-1');
expect(getStore().posts).toHaveLength(0);
});
it('should clear selectedPostId when the selected post is removed', () => {
const post = createMockPost({ id: 'post-1' });
getStore().addPost(post);
getStore().setSelectedPost('post-1');
expect(getStore().selectedPostId).toBe('post-1');
getStore().removePost('post-1');
expect(getStore().selectedPostId).toBeNull();
});
});
describe('Dirty Tracking', () => {
it('should mark a post as dirty', () => {
getStore().markDirty('post-1');
expect(getStore().isDirty('post-1')).toBe(true);
});
it('should mark a post as clean', () => {
getStore().markDirty('post-1');
getStore().markClean('post-1');
expect(getStore().isDirty('post-1')).toBe(false);
});
it('should track multiple dirty posts independently', () => {
getStore().markDirty('post-1');
getStore().markDirty('post-2');
expect(getStore().isDirty('post-1')).toBe(true);
expect(getStore().isDirty('post-2')).toBe(true);
getStore().markClean('post-1');
expect(getStore().isDirty('post-1')).toBe(false);
expect(getStore().isDirty('post-2')).toBe(true);
});
it('should return false for non-dirty posts', () => {
expect(getStore().isDirty('non-existent-post')).toBe(false);
});
});
describe('Post Selection', () => {
it('should set selected post ID', () => {
getStore().setSelectedPost('post-1');
expect(getStore().selectedPostId).toBe('post-1');
});
it('should clear selected post ID when set to null', () => {
getStore().setSelectedPost('post-1');
getStore().setSelectedPost(null);
expect(getStore().selectedPostId).toBeNull();
});
});
describe('UI State', () => {
it('should toggle sidebar visibility', () => {
const initialState = getStore().sidebarVisible;
getStore().toggleSidebar();
expect(getStore().sidebarVisible).toBe(!initialState);
});
it('should set active view', () => {
getStore().setActiveView('media');
expect(getStore().activeView).toBe('media');
});
it('should set preferred editor mode', () => {
getStore().setPreferredEditorMode('markdown');
expect(getStore().preferredEditorMode).toBe('markdown');
});
});
});

View File

@@ -4,6 +4,88 @@
*/
import { vi, beforeEach, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
// Mock localStorage for Zustand persist middleware
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] || null),
};
})();
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock,
writable: true,
});
// Mock window.electronAPI for renderer tests
Object.defineProperty(globalThis, 'window', {
value: {
electronAPI: {
posts: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
getAll: vi.fn(),
getByStatus: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
rebuildFromFiles: vi.fn(),
search: vi.fn(),
filter: vi.fn(),
getTags: vi.fn(),
getCategories: vi.fn(),
getByYearMonth: vi.fn(),
getLinksTo: vi.fn(),
getLinkedBy: vi.fn(),
rebuildLinks: vi.fn(),
},
media: {
import: vi.fn(),
importDialog: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
getAll: vi.fn(),
rebuildFromFiles: vi.fn(),
getThumbnail: vi.fn(),
regenerateThumbnails: vi.fn(),
},
sync: {
configure: vi.fn(),
start: vi.fn(),
getStatus: vi.fn(),
isConfigured: vi.fn(),
getPendingCount: vi.fn(),
getLog: vi.fn(),
stopAutoSync: vi.fn(),
},
tasks: {
getAll: vi.fn(),
getRunning: vi.fn(),
cancel: vi.fn(),
clearCompleted: vi.fn(),
},
on: vi.fn(() => () => {}),
},
},
writable: true,
});
// Mock Electron app module
vi.mock('electron', () => ({

View File

@@ -1,20 +1,28 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
// Use jsdom for React component tests, node for main process tests
environment: 'jsdom',
environmentMatchGlobs: [
// Use node environment for main process engine tests
['tests/engine/**', 'node'],
],
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'tests/**/*.test.ts', 'tests/**/*.test.tsx'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/main/**/*.ts'],
include: ['src/main/**/*.ts', 'src/renderer/**/*.ts', 'src/renderer/**/*.tsx'],
exclude: [
'src/main/main.ts',
'src/main/preload.ts',
'src/**/*.test.ts',
'src/**/*.test.tsx',
],
},
setupFiles: ['./tests/setup.ts'],