feat: more cleanup work in UI
This commit is contained in:
33
VISION.md
33
VISION.md
@@ -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
999
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
background-color: var(--vscode-notificationsErrorIcon-foreground);
|
||||
}
|
||||
|
||||
.auto-save-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store';
|
||||
import { useAppStore, PostData, EditorMode } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { WysiwygEditor } from '../WysiwygEditor';
|
||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
@@ -38,14 +38,11 @@ const markdownToHtml = (markdown: string): string => {
|
||||
.replace(/\n/g, '<br />');
|
||||
};
|
||||
|
||||
// Check if an ID is for an unsaved draft
|
||||
const isUnsavedDraftId = (id: string): boolean => id.startsWith('draft-');
|
||||
|
||||
interface SavedPostEditorProps {
|
||||
interface PostEditorProps {
|
||||
post: PostData;
|
||||
}
|
||||
|
||||
const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const {
|
||||
updatePost,
|
||||
markDirty,
|
||||
@@ -61,6 +58,7 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
const [tags, setTags] = useState(post.tags.join(', '));
|
||||
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
@@ -68,10 +66,62 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
|
||||
const isDirty = checkIsDirty(post.id);
|
||||
|
||||
// Check if post has a published version for discard functionality
|
||||
useEffect(() => {
|
||||
window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion);
|
||||
}, [post.id]);
|
||||
|
||||
// Extract images from content for lightbox
|
||||
const images = useMarkdownImages(content);
|
||||
|
||||
// Reset when post changes
|
||||
// Track latest values for auto-save on unmount/switch
|
||||
const pendingChangesRef = useRef<{
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
categories: string;
|
||||
postId: string;
|
||||
isDirty: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Update ref when values change
|
||||
useEffect(() => {
|
||||
pendingChangesRef.current = {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
categories,
|
||||
postId: post.id,
|
||||
isDirty,
|
||||
};
|
||||
}, [title, content, tags, categories, post.id, isDirty]);
|
||||
|
||||
// Auto-save when switching away from a post or unmounting
|
||||
useEffect(() => {
|
||||
const prevPostId = post.id;
|
||||
|
||||
return () => {
|
||||
const pending = pendingChangesRef.current;
|
||||
if (pending && pending.postId === prevPostId && pending.isDirty) {
|
||||
// Fire and forget auto-save
|
||||
window.electronAPI?.posts.update(pending.postId, {
|
||||
title: pending.title,
|
||||
content: pending.content,
|
||||
tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
categories: pending.categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||
}).then((updated) => {
|
||||
if (updated) {
|
||||
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
|
||||
useAppStore.getState().markClean(pending.postId);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Auto-save failed:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [post.id]);
|
||||
|
||||
// Reset when post changes (after auto-save cleanup runs)
|
||||
useEffect(() => {
|
||||
setTitle(post.title);
|
||||
setContent(post.content);
|
||||
@@ -168,6 +218,48 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = async () => {
|
||||
// If this post has a published version, revert to it
|
||||
// If never published, delete the post entirely
|
||||
const confirmMessage = hasPublishedVersion
|
||||
? 'Discard all changes since last publish? This cannot be undone.'
|
||||
: 'Delete this draft? This cannot be undone.';
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasPublishedVersion) {
|
||||
// Revert to published version
|
||||
const reverted = await window.electronAPI?.posts.discard(post.id);
|
||||
if (reverted) {
|
||||
setTitle(reverted.title);
|
||||
setContent(reverted.content);
|
||||
setTags(reverted.tags.join(', '));
|
||||
setCategories(reverted.categories.join(', '));
|
||||
updatePost(post.id, reverted as Partial<PostData>);
|
||||
markClean(post.id);
|
||||
showToast.success('Reverted to last published version');
|
||||
}
|
||||
} else {
|
||||
// Never published - delete the post entirely
|
||||
await window.electronAPI?.posts.delete(post.id);
|
||||
useAppStore.getState().removePost(post.id);
|
||||
useAppStore.getState().setSelectedPost(null);
|
||||
showToast.success('Draft deleted');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discard/delete:', error);
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed',
|
||||
message: err.message || 'Operation failed',
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this post?')) {
|
||||
try {
|
||||
@@ -224,24 +316,41 @@ const SavedPostEditor: React.FC<SavedPostEditorProps> = ({ post }) => {
|
||||
<div className="editor-tabs">
|
||||
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
||||
<span className="editor-tab-title">{title || 'Untitled'}</span>
|
||||
{isDirty && <span className="editor-tab-dirty">●</span>}
|
||||
{isDirty && <span className="editor-tab-dirty" title="Unsaved changes (auto-saves on switch)">●</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<span className={`status-badge status-${post.status}`}>
|
||||
{post.status}
|
||||
</span>
|
||||
{isSaving && <span className="auto-save-indicator">Saving...</span>}
|
||||
{post.status === 'draft' ? (
|
||||
<button onClick={handlePublish} title="Publish">Publish</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
className="success"
|
||||
title="Save and make this post public"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
className="secondary"
|
||||
title="Return to draft status"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} disabled={!isDirty || isSaving} title="Save (Ctrl+S)">
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
{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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore, PostData, UnsavedDraft } from '../../store';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import './Sidebar.css';
|
||||
|
||||
@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
};
|
||||
|
||||
const PostsList: React.FC = () => {
|
||||
const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore();
|
||||
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -321,11 +321,23 @@ const PostsList: React.FC = () => {
|
||||
applyFilters();
|
||||
}, [selectedTags, selectedCategories]);
|
||||
|
||||
const handleCreatePost = () => {
|
||||
// Create an unsaved draft instead of immediately saving to database
|
||||
const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState();
|
||||
const draftId = createUnsavedDraft();
|
||||
selectPost(draftId);
|
||||
const handleCreatePost = async () => {
|
||||
// Create a real post immediately in the database with default empty content
|
||||
try {
|
||||
const { addPost, setSelectedPost: selectPost } = useAppStore.getState();
|
||||
const newPost = await window.electronAPI?.posts.create({
|
||||
title: 'Untitled',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
});
|
||||
if (newPost) {
|
||||
addPost(newPost as PostData);
|
||||
selectPost(newPost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which posts to display
|
||||
@@ -405,34 +417,6 @@ const PostsList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved Drafts Section - always show at top if there are any */}
|
||||
{unsavedDrafts.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
<span className="section-icon status-unsaved">●</span>
|
||||
Unsaved ({unsavedDrafts.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{unsavedDrafts.map((draft: UnsavedDraft) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className={`sidebar-item post-type-new unsaved ${selectedPostId === draft.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(draft.id)}
|
||||
>
|
||||
<span className="post-type-icon" title="New post">✨</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">
|
||||
{draft.title || 'New Post'}
|
||||
<span className="unsaved-indicator">●</span>
|
||||
</div>
|
||||
<div className="sidebar-item-meta">Not yet saved</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedPosts.draft.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
|
||||
import { FloatingMenu } from '@tiptap/extension-floating-menu';
|
||||
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TurndownService from 'turndown';
|
||||
import './WysiwygEditor.css';
|
||||
|
||||
@@ -88,6 +87,10 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
onChange,
|
||||
placeholder = 'Start writing your content...',
|
||||
}) => {
|
||||
// Track if we're updating from internal changes vs external prop changes
|
||||
const isInternalChange = useRef(false);
|
||||
const lastExternalContent = useRef(content);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
@@ -101,34 +104,38 @@ export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
class: 'editor-link',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'editor-image',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: markdownToHtml(content),
|
||||
onUpdate: ({ editor }) => {
|
||||
isInternalChange.current = true;
|
||||
const html = editor.getHTML();
|
||||
const markdown = turndownService.turndown(html);
|
||||
onChange(markdown);
|
||||
},
|
||||
editable: true,
|
||||
});
|
||||
|
||||
// Sync content from external changes only (e.g., post switch)
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
const currentHtml = editor.getHTML();
|
||||
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' : ''}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob:; worker-src 'self' blob:; font-src 'self' data:;" />
|
||||
<title>Blogging Desktop Server</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
// Configure Monaco to use local bundled version instead of CDN
|
||||
// This avoids CSP issues in Electron
|
||||
loader.config({ monaco });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -30,17 +30,6 @@ export interface PostData {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
// Unsaved draft that only exists in memory/local storage until saved
|
||||
export interface UnsavedDraft {
|
||||
id: string; // Temporary ID (prefixed with 'draft-')
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
createdAt: string;
|
||||
isNew: true; // Always true for unsaved drafts
|
||||
}
|
||||
|
||||
export interface MediaData {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -93,9 +82,7 @@ interface AppState {
|
||||
media: MediaData[];
|
||||
tasks: TaskProgress[];
|
||||
|
||||
// Unsaved drafts (memory only until saved)
|
||||
unsavedDrafts: UnsavedDraft[];
|
||||
// Track which posts have unsaved changes (by post ID or draft ID)
|
||||
// Track which posts have unsaved changes (by post ID)
|
||||
dirtyPosts: Set<string>;
|
||||
|
||||
// Error modal
|
||||
@@ -130,12 +117,6 @@ interface AppState {
|
||||
updatePost: (id: string, post: Partial<PostData>) => void;
|
||||
removePost: (id: string) => void;
|
||||
|
||||
// Unsaved draft actions
|
||||
createUnsavedDraft: () => string; // Returns the draft ID
|
||||
updateUnsavedDraft: (id: string, data: Partial<UnsavedDraft>) => void;
|
||||
removeUnsavedDraft: (id: string) => void;
|
||||
getUnsavedDraft: (id: string) => UnsavedDraft | undefined;
|
||||
|
||||
// Dirty tracking
|
||||
markDirty: (id: string) => void;
|
||||
markClean: (id: string) => void;
|
||||
@@ -181,8 +162,7 @@ export const useAppStore = create<AppState>()(
|
||||
media: [],
|
||||
tasks: [],
|
||||
|
||||
// Unsaved drafts
|
||||
unsavedDrafts: [],
|
||||
// Dirty posts tracking
|
||||
dirtyPosts: new Set<string>(),
|
||||
|
||||
// Error modal
|
||||
@@ -222,49 +202,16 @@ export const useAppStore = create<AppState>()(
|
||||
updatePost: (id, updatedPost) => set((state) => ({
|
||||
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
|
||||
})),
|
||||
removePost: (id) => set((state) => ({
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
})),
|
||||
|
||||
// Unsaved draft actions
|
||||
createUnsavedDraft: () => {
|
||||
const id = `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const draft: UnsavedDraft = {
|
||||
id,
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
isNew: true,
|
||||
};
|
||||
set((state) => ({
|
||||
unsavedDrafts: [...state.unsavedDrafts, draft],
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateUnsavedDraft: (id, data) => set((state) => ({
|
||||
unsavedDrafts: state.unsavedDrafts.map((d) =>
|
||||
d.id === id ? { ...d, ...data } : d
|
||||
),
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
})),
|
||||
|
||||
removeUnsavedDraft: (id) => set((state) => {
|
||||
removePost: (id) => set((state) => {
|
||||
const newDirtyPosts = new Set(state.dirtyPosts);
|
||||
newDirtyPosts.delete(id);
|
||||
return {
|
||||
unsavedDrafts: state.unsavedDrafts.filter((d) => d.id !== id),
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
dirtyPosts: newDirtyPosts,
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
};
|
||||
}),
|
||||
|
||||
getUnsavedDraft: (id) => get().unsavedDrafts.find((d) => d.id === id),
|
||||
|
||||
// Dirty tracking
|
||||
markDirty: (id) => set((state) => ({
|
||||
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
||||
@@ -318,8 +265,6 @@ export const useAppStore = create<AppState>()(
|
||||
selectedPostId: state.selectedPostId,
|
||||
selectedMediaId: state.selectedMediaId,
|
||||
preferredEditorMode: state.preferredEditorMode,
|
||||
// Persist unsaved drafts for recovery
|
||||
unsavedDrafts: state.unsavedDrafts,
|
||||
// Convert Set to array for storage
|
||||
dirtyPosts: [...state.dirtyPosts],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
type PostData,
|
||||
type MediaData,
|
||||
type TaskProgress,
|
||||
type UnsavedDraft,
|
||||
type EditorMode,
|
||||
type ErrorDetails
|
||||
} from './appStore';
|
||||
|
||||
@@ -164,6 +164,31 @@ button.secondary:hover {
|
||||
background-color: #4a4d51;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--vscode-button-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
button.success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
button.success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
7
src/renderer/types/electron.d.ts
vendored
7
src/renderer/types/electron.d.ts
vendored
@@ -104,12 +104,19 @@ export interface ElectronAPI {
|
||||
getByStatus: (status: string) => Promise<PostData[]>;
|
||||
publish: (id: string) => Promise<PostData | null>;
|
||||
unpublish: (id: string) => Promise<PostData | null>;
|
||||
discard: (id: string) => Promise<PostData | null>;
|
||||
hasPublishedVersion: (id: string) => Promise<boolean>;
|
||||
rebuildFromFiles: () => Promise<void>;
|
||||
search: (query: string) => Promise<SearchResult[]>;
|
||||
filter: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
getLinksTo: (id: string) => Promise<PostData[]>;
|
||||
getLinkedBy: (id: string) => Promise<PostData[]>;
|
||||
rebuildLinks: () => Promise<void>;
|
||||
isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>;
|
||||
generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||
|
||||
252
tests/renderer/components/Editor.test.ts
Normal file
252
tests/renderer/components/Editor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
tests/renderer/store/appStore.test.ts
Normal file
163
tests/renderer/store/appStore.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user