feat: more feature implementations

This commit is contained in:
2026-02-10 13:40:44 +01:00
parent 867b22add0
commit 9f35e74d0f
33 changed files with 4560 additions and 130 deletions

View File

@@ -84,6 +84,21 @@ This is a short-form article that does not need a full-article-page, because it
a short comment that is shown after the link. This is meant for link collections and should be rendered a short comment that is shown after the link. This is meant for link collections and should be rendered
in a compact form in the overview pages. More on rendering in the publishing pipelin description. in a compact form in the overview pages. More on rendering in the publishing pipelin description.
### category "page"
This is a post that behaves mostly like an article, but is ignored in overview pages, because it is just
meant to be linked to menues. So menu editing needs to be able to reference posts of category page
and overview templates need to ignore posts of category page. Other than that, they are just like article,
so have long form text, short form summary and title. Pages can be assigned different header images that
override the project image.
### Project definition
A project is not just the collection of posts, media and publish settings, it is also a base project
container with title, author and other elements like that. So there needs to be project related settings
that allow to give the project a title, set up a main author that can be referenced in metadat and for
example set up header images that can be used when publishing.
## Migrating ## Migrating
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old
@@ -141,6 +156,12 @@ system and also create blog posts with AI support.
All SDK tools must also be made available as MCP server that is hosted inside the application, so that I All SDK tools must also be made available as MCP server that is hosted inside the application, so that I
can hook the app into a normal AI coding agent. can hook the app into a normal AI coding agent.
Also the AI should be available to create summaries just with a button click in the post editor area, so
that the summary is filled in based on AI summarization to help speed up the blogging process. Also the
AI can be used to generate a good slurl that is not just generically from the title and even the title
can be AI-generated from the text. That way the user can focus on writing the core text and if all matches,
just accept AI summary and title and post it.
## Publishing ## Publishing
Publishing should target static HTML/CSS/JavaScript situations. There must be a asnyc exporter, that will render Publishing should target static HTML/CSS/JavaScript situations. There must be a asnyc exporter, that will render
@@ -168,7 +189,9 @@ maybe even with easy importing from a central bootstrap site or something like t
Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be
capable of building with this tooling. So we need templates for overview pages and ways to manage menues capable of building with this tooling. So we need templates for overview pages and ways to manage menues
that reference overview pages and structure the menu according to site structure. Also support calendar views that reference overview pages and structure the menu according to site structure. Also support calendar views
to allow users to go to specific months and years of the blog. to allow users to go to specific months and years of the blog. Build up a sensible set of templates that
come with a new project, so that the user can start right away without a lot of hassle. Base the templates
on the structure of above website, but keep out website title and images, of course.
Categories and tags must be able to define a template selection for post templates, so that different types Categories and tags must be able to define a template selection for post templates, so that different types
can be represented differently. this is especially important for the standard categories "article", "picture" can be represented differently. this is especially important for the standard categories "article", "picture"

1416
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,14 +43,25 @@
"dependencies": { "dependencies": {
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-underline": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"@types/turndown": "^5.0.6",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.29.0", "drizzle-orm": "^0.29.0",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"marked-react": "^3.0.2",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"sharp": "^0.34.5",
"turndown": "^7.2.2",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },

View File

@@ -190,12 +190,22 @@ export class DatabaseConnection {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS post_links (
id TEXT PRIMARY KEY,
source_post_id TEXT NOT NULL,
target_post_id TEXT NOT NULL,
link_text TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status); CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status); CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status); CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
`); `);
// Check if project_id column exists in posts table, add if missing (migration) // Check if project_id column exists in posts table, add if missing (migration)

View File

@@ -72,6 +72,15 @@ export const settings = sqliteTable('settings', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });
// Post links - tracks internal links between posts
export const postLinks = sqliteTable('post_links', {
id: text('id').primaryKey(),
sourcePostId: text('source_post_id').notNull(), // Post containing the link
targetPostId: text('target_post_id').notNull(), // Post being linked to
linkText: text('link_text'), // The text of the link
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Types for TypeScript // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -83,3 +92,5 @@ export type SyncLogEntry = typeof syncLog.$inferSelect;
export type NewSyncLogEntry = typeof syncLog.$inferInsert; export type NewSyncLogEntry = typeof syncLog.$inferInsert;
export type Setting = typeof settings.$inferSelect; export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert; export type NewSetting = typeof settings.$inferInsert;
export type PostLink = typeof postLinks.$inferSelect;
export type NewPostLink = typeof postLinks.$inferInsert;

View File

@@ -7,6 +7,15 @@ import { eq } from 'drizzle-orm';
import { app } from 'electron'; import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema'; import { media, Media, NewMedia } from '../database/schema';
// Thumbnail sizes
const THUMBNAIL_SIZES = {
small: { width: 150, height: 150 },
medium: { width: 400, height: 400 },
large: { width: 800, height: 800 },
} as const;
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
import { taskManager, Task } from './TaskManager'; import { taskManager, Task } from './TaskManager';
export interface MediaData { export interface MediaData {
@@ -88,6 +97,105 @@ export class MediaEngine extends EventEmitter {
return crypto.createHash('md5').update(buffer).digest('hex'); return crypto.createHash('md5').update(buffer).digest('hex');
} }
/**
* Get the thumbnails directory for the current project
*/
private getThumbnailsDir(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'thumbnails');
}
/**
* Generate thumbnails for an image file
*/
async generateThumbnails(mediaId: string, sourcePath: string): Promise<Record<ThumbnailSize, string>> {
const thumbnailsDir = this.getThumbnailsDir();
await fs.mkdir(thumbnailsDir, { recursive: true });
const thumbnails: Record<ThumbnailSize, string> = {} as Record<ThumbnailSize, string>;
try {
// Dynamic import of sharp (it's a native module)
const sharp = (await import('sharp')).default;
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
await sharp(sourcePath)
.resize(dimensions.width, dimensions.height, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toFile(thumbnailPath);
thumbnails[size] = thumbnailPath;
}
this.emit('thumbnailsGenerated', { mediaId, thumbnails });
} catch (error) {
console.error('Failed to generate thumbnails:', error);
// Return empty thumbnails on error - non-critical failure
}
return thumbnails;
}
/**
* Get existing thumbnail paths for a media item
*/
async getThumbnailPaths(mediaId: string): Promise<Record<ThumbnailSize, string | null>> {
const thumbnailsDir = this.getThumbnailsDir();
const result: Record<ThumbnailSize, string | null> = {
small: null,
medium: null,
large: null,
};
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
try {
await fs.access(thumbnailPath);
result[size] = thumbnailPath;
} catch {
// Thumbnail doesn't exist
}
}
return result;
}
/**
* Get thumbnail as base64 data URL for renderer
*/
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
const thumbnailsDir = this.getThumbnailsDir();
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
try {
const data = await fs.readFile(thumbnailPath);
return `data:image/webp;base64,${data.toString('base64')}`;
} catch {
return null;
}
}
/**
* Delete thumbnails for a media item
*/
private async deleteThumbnails(mediaId: string): Promise<void> {
const thumbnailsDir = this.getThumbnailsDir();
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
try {
await fs.unlink(thumbnailPath);
} catch {
// Thumbnail doesn't exist, ignore
}
}
}
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> { private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
const sidecarPath = `${mediaPath}.meta`; const sidecarPath = `${mediaPath}.meta`;
@@ -239,14 +347,30 @@ export class MediaEngine extends EventEmitter {
// Copy file to media directory // Copy file to media directory
await fs.copyFile(sourcePath, destPath); await fs.copyFile(sourcePath, destPath);
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
let width = metadata?.width;
let height = metadata?.height;
// Get image dimensions using sharp if it's an image
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
try {
const sharp = (await import('sharp')).default;
const imageMetadata = await sharp(destPath).metadata();
width = imageMetadata.width;
height = imageMetadata.height;
} catch (error) {
console.error('Failed to get image dimensions:', error);
}
}
const mediaData: MediaData = { const mediaData: MediaData = {
id, id,
filename, filename,
originalName, originalName,
mimeType: metadata?.mimeType || this.getMimeType(originalName), mimeType,
size: sourceBuffer.length, size: sourceBuffer.length,
width: metadata?.width, width,
height: metadata?.height, height,
alt: metadata?.alt, alt: metadata?.alt,
caption: metadata?.caption, caption: metadata?.caption,
createdAt: now, createdAt: now,
@@ -257,6 +381,13 @@ export class MediaEngine extends EventEmitter {
const sidecarPath = await this.writeSidecarFile(mediaData, destPath); const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
const checksum = this.calculateChecksum(sourceBuffer); const checksum = this.calculateChecksum(sourceBuffer);
// Generate thumbnails for images (async, non-blocking)
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
this.generateThumbnails(id, destPath).catch(err => {
console.error('Failed to generate thumbnails:', err);
});
}
const dbMedia: NewMedia = { const dbMedia: NewMedia = {
id: mediaData.id, id: mediaData.id,
projectId: this.currentProjectId, projectId: this.currentProjectId,
@@ -339,6 +470,9 @@ export class MediaEngine extends EventEmitter {
// File might not exist // File might not exist
} }
// Delete thumbnails
await this.deleteThumbnails(id);
await db.delete(media).where(eq(media.id, id)); await db.delete(media).where(eq(media.id, id));
this.emit('mediaDeleted', id); this.emit('mediaDeleted', id);

View File

@@ -7,7 +7,7 @@ import matter from 'gray-matter';
import { eq, and, desc, gte, lte, like } from 'drizzle-orm'; import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
import { app } from 'electron'; import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { posts, Post, NewPost } from '../database/schema'; import { posts, Post, NewPost, postLinks } from '../database/schema';
import { taskManager, Task } from './TaskManager'; import { taskManager, Task } from './TaskManager';
export interface PostData { export interface PostData {
@@ -289,6 +289,11 @@ export class PostEngine extends EventEmitter {
}); });
} }
// Update post links if content changed
if (data.content) {
await this.updatePostLinks(id, updated.content);
}
this.emit('postUpdated', updated); this.emit('postUpdated', updated);
return updated; return updated;
} }
@@ -635,6 +640,140 @@ export class PostEngine extends EventEmitter {
await taskManager.runTask(task); await taskManager.runTask(task);
} }
/**
* Extract internal post links from content (links to other posts in the blog)
*/
extractInternalLinks(content: string): { slug: string; text: string }[] {
const links: { slug: string; text: string }[] = [];
// Match markdown links: [text](/posts/slug) or [text](/year/month/slug)
const markdownLinkRegex = /\[([^\]]+)\]\(\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?\)/gi;
let match;
while ((match = markdownLinkRegex.exec(content)) !== null) {
links.push({ text: match[1], slug: match[2] });
}
// Match HTML links: <a href="/posts/slug">text</a>
const htmlLinkRegex = /<a[^>]+href=["']\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?["'][^>]*>([^<]+)<\/a>/gi;
while ((match = htmlLinkRegex.exec(content)) !== null) {
links.push({ text: match[2], slug: match[1] });
}
return links;
}
/**
* Update post links in the database based on content analysis
*/
async updatePostLinks(postId: string, content: string): Promise<void> {
const db = getDatabase().getLocal();
const extractedLinks = this.extractInternalLinks(content);
// Delete existing links from this post
await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId));
if (extractedLinks.length === 0) return;
// Get all posts to resolve slugs to IDs
const allPosts = await db.select({ id: posts.id, slug: posts.slug })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
const slugToId = new Map(allPosts.map(p => [p.slug, p.id]));
// Insert new links
for (const link of extractedLinks) {
const targetId = slugToId.get(link.slug);
if (targetId && targetId !== postId) {
await db.insert(postLinks).values({
id: uuidv4(),
sourcePostId: postId,
targetPostId: targetId,
linkText: link.text,
createdAt: new Date(),
});
}
}
}
/**
* Get posts that link TO the specified post ("linked by")
*/
async getLinkedBy(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
const db = getDatabase().getLocal();
const links = await db
.select({
sourcePostId: postLinks.sourcePostId,
linkText: postLinks.linkText,
})
.from(postLinks)
.where(eq(postLinks.targetPostId, postId));
if (links.length === 0) return [];
const sourceIds = links.map(l => l.sourcePostId);
const sourcePosts = await db
.select({ id: posts.id, title: posts.title, slug: posts.slug })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
return sourcePosts.filter(p => sourceIds.includes(p.id));
}
/**
* Get posts that the specified post links TO ("links to")
*/
async getLinksTo(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
const db = getDatabase().getLocal();
const links = await db
.select({
targetPostId: postLinks.targetPostId,
linkText: postLinks.linkText,
})
.from(postLinks)
.where(eq(postLinks.sourcePostId, postId));
if (links.length === 0) return [];
const targetIds = links.map(l => l.targetPostId);
const targetPosts = await db
.select({ id: posts.id, title: posts.title, slug: posts.slug })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
return targetPosts.filter(p => targetIds.includes(p.id));
}
/**
* Rebuild all post links from content analysis
*/
async rebuildAllPostLinks(): Promise<void> {
const db = getDatabase().getLocal();
// Clear all existing links
await db.delete(postLinks);
// Get all posts
const allPosts = await db
.select({ id: posts.id, filePath: posts.filePath })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
for (const post of allPosts) {
try {
const fileContent = await fs.readFile(post.filePath, 'utf-8');
const { content } = matter(fileContent);
await this.updatePostLinks(post.id, content);
} catch (error) {
console.error(`Failed to update links for post ${post.id}:`, error);
}
}
this.emit('postLinksRebuilt');
}
} }
// Singleton instance // Singleton instance

View File

@@ -1,10 +1,12 @@
import { ipcMain, dialog, shell } from 'electron'; import { ipcMain, dialog, shell } from 'electron';
import { eq } from 'drizzle-orm';
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine'; import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
import { getMediaEngine, MediaData } from '../engine/MediaEngine'; import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine'; import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager'; import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media } from '../database/schema';
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
// ============ Project Handlers ============ // ============ Project Handlers ============
@@ -126,6 +128,21 @@ export function registerIpcHandlers(): void {
return engine.getPostsByYearMonth(); return engine.getPostsByYearMonth();
}); });
ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
const engine = getPostEngine();
return engine.getLinksTo(id);
});
ipcMain.handle('posts:getLinkedBy', async (_, id: string) => {
const engine = getPostEngine();
return engine.getLinkedBy(id);
});
ipcMain.handle('posts:rebuildLinks', async () => {
const engine = getPostEngine();
return engine.rebuildAllPostLinks();
});
// ============ Media Handlers ============ // ============ Media Handlers ============
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => { ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
@@ -187,6 +204,24 @@ export function registerIpcHandlers(): void {
return engine.rebuildDatabaseFromFiles(); return engine.rebuildDatabaseFromFiles();
}); });
ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
const engine = getMediaEngine();
return engine.getThumbnailDataUrl(id, size || 'small');
});
ipcMain.handle('media:regenerateThumbnails', async (_, id: string) => {
const engine = getMediaEngine();
const mediaItem = await engine.getMedia(id);
if (mediaItem && mediaItem.mimeType.startsWith('image/')) {
const db = getDatabase().getLocal();
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (dbMedia) {
return engine.generateThumbnails(id, dbMedia.filePath);
}
}
return null;
});
// ============ Sync Handlers ============ // ============ Sync Handlers ============
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => { ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {

View File

@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTags: () => ipcRenderer.invoke('posts:getTags'), getTags: () => ipcRenderer.invoke('posts:getTags'),
getCategories: () => ipcRenderer.invoke('posts:getCategories'), getCategories: () => ipcRenderer.invoke('posts:getCategories'),
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'), getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
}, },
// Media // Media
@@ -41,6 +44,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
get: (id: string) => ipcRenderer.invoke('media:get', id), get: (id: string) => ipcRenderer.invoke('media:get', id),
getAll: () => ipcRenderer.invoke('media:getAll'), getAll: () => ipcRenderer.invoke('media:getAll'),
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'), rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
}, },
// Sync // Sync
@@ -107,6 +112,9 @@ export interface ElectronAPI {
getTags: () => Promise<string[]>; getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>; getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>; getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
rebuildLinks: () => Promise<void>;
}; };
media: { media: {
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>; import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;

View File

@@ -0,0 +1,106 @@
.credentials-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.credentials-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
}
.credentials-tabs button {
padding: 6px 12px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.credentials-tabs button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.credentials-tabs button.active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.credentials-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.credentials-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.credentials-header h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--vscode-foreground);
}
.credentials-header p {
font-size: 12px;
margin: 0;
line-height: 1.5;
}
.credentials-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.credentials-field label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
}
.credentials-field input {
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.toggle-visibility {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
font-size: 14px;
opacity: 0.7;
transition: opacity 0.15s;
}
.toggle-visibility:hover {
opacity: 1;
}
.credentials-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--vscode-panel-border);
}
.credentials-actions button {
padding: 8px 16px;
font-size: 12px;
}

View File

@@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react';
import { showToast } from '../Toast';
import './CredentialsPanel.css';
interface Credentials {
tursoUrl: string;
tursoToken: string;
ftpHost?: string;
ftpUser?: string;
ftpPassword?: string;
sshHost?: string;
sshUser?: string;
sshKeyPath?: string;
}
export const CredentialsPanel: React.FC = () => {
const [credentials, setCredentials] = useState<Credentials>({
tursoUrl: '',
tursoToken: '',
ftpHost: '',
ftpUser: '',
ftpPassword: '',
sshHost: '',
sshUser: '',
sshKeyPath: '',
});
const [activeTab, setActiveTab] = useState<'sync' | 'ftp' | 'ssh'>('sync');
const [showTokens, setShowTokens] = useState(false);
// Load saved credentials (in a real app, use secure storage)
useEffect(() => {
const loadCredentials = async () => {
try {
const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) {
setCredentials(JSON.parse(savedCreds));
}
} catch (error) {
console.error('Failed to load credentials:', error);
}
};
loadCredentials();
}, []);
const handleSave = async () => {
try {
// Save to localStorage (in production, use secure storage)
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
// Configure sync if Turso credentials are set
if (credentials.tursoUrl && credentials.tursoToken) {
await window.electronAPI?.sync.configure({
tursoUrl: credentials.tursoUrl,
tursoAuthToken: credentials.tursoToken,
autoSync: true,
syncInterval: 5,
});
}
showToast.success('Credentials saved');
} catch (error) {
console.error('Failed to save credentials:', error);
showToast.error('Failed to save credentials');
}
};
const handleClear = (type: 'sync' | 'ftp' | 'ssh') => {
const newCreds = { ...credentials };
switch (type) {
case 'sync':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
break;
case 'ftp':
newCreds.ftpHost = '';
newCreds.ftpUser = '';
newCreds.ftpPassword = '';
break;
case 'ssh':
newCreds.sshHost = '';
newCreds.sshUser = '';
newCreds.sshKeyPath = '';
break;
}
setCredentials(newCreds);
};
const handleTestConnection = async (type: 'sync' | 'ftp' | 'ssh') => {
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
// Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500));
// In a real implementation, this would test the actual connection
if (type === 'sync' && credentials.tursoUrl && credentials.tursoToken) {
showToast.dismiss();
showToast.success('Sync connection successful');
} else {
showToast.dismiss();
showToast.error('Connection failed - check credentials');
}
};
return (
<div className="credentials-panel">
<div className="credentials-tabs">
<button
className={activeTab === 'sync' ? 'active' : ''}
onClick={() => setActiveTab('sync')}
>
Cloud Sync
</button>
<button
className={activeTab === 'ftp' ? 'active' : ''}
onClick={() => setActiveTab('ftp')}
>
FTP
</button>
<button
className={activeTab === 'ssh' ? 'active' : ''}
onClick={() => setActiveTab('ssh')}
>
SSH
</button>
</div>
<div className="credentials-content">
{activeTab === 'sync' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>Turso/LibSQL Cloud Sync</h4>
<p className="text-muted">
Connect to Turso for cloud database synchronization.
</p>
</div>
<div className="credentials-field">
<label>Database URL</label>
<input
type="text"
placeholder="libsql://your-database.turso.io"
value={credentials.tursoUrl}
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>
Auth Token
<button
className="toggle-visibility"
onClick={() => setShowTokens(!showTokens)}
>
{showTokens ? '👁' : '👁‍🗨'}
</button>
</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Your authentication token"
value={credentials.tursoToken}
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('sync')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('sync')}>
Clear
</button>
</div>
</div>
)}
{activeTab === 'ftp' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>FTP Publishing</h4>
<p className="text-muted">
Configure FTP for publishing your blog to a web server.
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<input
type="text"
placeholder="ftp.example.com"
value={credentials.ftpHost}
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<input
type="text"
placeholder="ftp-user"
value={credentials.ftpUser}
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Password</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Password"
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('ftp')}>
Clear
</button>
</div>
</div>
)}
{activeTab === 'ssh' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>SSH Publishing</h4>
<p className="text-muted">
Configure SSH for secure publishing to your server.
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<input
type="text"
placeholder="server.example.com"
value={credentials.sshHost}
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<input
type="text"
placeholder="ssh-user"
value={credentials.sshUser}
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>SSH Key Path</label>
<input
type="text"
placeholder="~/.ssh/id_rsa"
value={credentials.sshKeyPath}
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('ssh')}>
Clear
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default CredentialsPanel;

View File

@@ -0,0 +1 @@
export { CredentialsPanel } from './CredentialsPanel';

View File

@@ -186,6 +186,22 @@
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground);
} }
.gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
margin-left: auto;
}
.gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.editor-preview { .editor-preview {
flex: 1; flex: 1;
background-color: var(--vscode-input-background); background-color: var(--vscode-input-background);
@@ -196,6 +212,63 @@
line-height: 1.6; line-height: 1.6;
} }
.editor-preview .preview-content {
max-width: 800px;
margin: 0 auto;
}
.editor-preview h1,
.editor-preview h2,
.editor-preview h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--vscode-foreground);
}
.editor-preview h1 { font-size: 2em; }
.editor-preview h2 { font-size: 1.5em; }
.editor-preview h3 { font-size: 1.25em; }
.editor-preview code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
}
.editor-preview pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.editor-preview pre code {
background: none;
padding: 0;
}
.editor-preview blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 16px;
margin-left: 0;
color: var(--vscode-descriptionForeground);
}
.editor-preview a {
color: var(--vscode-textLink-foreground);
}
.editor-preview a:hover {
color: var(--vscode-textLink-activeForeground);
}
.editor-preview img {
max-width: 100%;
border-radius: 6px;
cursor: pointer;
}
.editor-field-row { .editor-field-row {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -2,8 +2,43 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import MonacoEditor from '@monaco-editor/react'; import MonacoEditor from '@monaco-editor/react';
import { useAppStore, PostData } from '../../store'; import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import { WysiwygEditor } from '../WysiwygEditor';
import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks';
import './Editor.css'; import './Editor.css';
// Simple markdown to HTML converter for preview
const markdownToHtml = (markdown: string): string => {
return markdown
// Escape HTML
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
// Italic
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
// Images
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img alt="$1" src="$2" style="max-width: 100%;" />')
// Links
.replace(/\[(.*?)\]\((.*?)\)/gim, '<a href="$2" target="_blank">$1</a>')
// Code blocks
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
// Inline code
.replace(/`(.*?)`/gim, '<code>$1</code>')
// Blockquotes
.replace(/^\> (.*$)/gim, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gim, '<hr />')
// Line breaks
.replace(/\n/g, '<br />');
};
type EditorMode = 'markdown' | 'wysiwyg' | 'preview';
interface PostEditorProps { interface PostEditorProps {
post: PostData; post: PostData;
} }
@@ -15,9 +50,14 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const [tags, setTags] = useState(post.tags.join(', ')); const [tags, setTags] = useState(post.tags.join(', '));
const [categories, setCategories] = useState(post.categories.join(', ')); const [categories, setCategories] = useState(post.categories.join(', '));
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown'); const [editorMode, setEditorMode] = useState<EditorMode>('wysiwyg');
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const editorRef = useRef<unknown>(null); const editorRef = useRef<unknown>(null);
// Extract images from content for lightbox
const images = useMarkdownImages(content);
// Reset when post changes // Reset when post changes
useEffect(() => { useEffect(() => {
setTitle(post.title); setTitle(post.title);
@@ -200,27 +240,59 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
/> />
</div> </div>
</div> </div>
<PostLinks
postId={post.id}
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
/>
</div> </div>
<div className="editor-body"> <div className="editor-body">
<div className="editor-toolbar"> <div className="editor-toolbar">
<label>Content (Markdown)</label> <label>Content</label>
<div className="editor-mode-toggle"> <div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => setEditorMode('wysiwyg')}
title="Visual editor"
>
Visual
</button>
<button <button
className={editorMode === 'markdown' ? 'active' : ''} className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => setEditorMode('markdown')} onClick={() => setEditorMode('markdown')}
title="Markdown source"
> >
Markdown Markdown
</button> </button>
<button <button
className={editorMode === 'preview' ? 'active' : ''} className={editorMode === 'preview' ? 'active' : ''}
onClick={() => setEditorMode('preview')} onClick={() => setEditorMode('preview')}
title="Read-only preview"
> >
Preview Preview
</button> </button>
</div> </div>
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
</div> </div>
{editorMode === 'markdown' ? (
{editorMode === 'wysiwyg' && (
<WysiwygEditor
content={content}
onChange={setContent}
placeholder="Start writing..."
/>
)}
{editorMode === 'markdown' && (
<MonacoEditor <MonacoEditor
height="100%" height="100%"
defaultLanguage="markdown" defaultLanguage="markdown"
@@ -244,13 +316,25 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
cursorBlinking: 'smooth', cursorBlinking: 'smooth',
}} }}
/> />
) : ( )}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body"> <div className="editor-preview markdown-body">
{/* Simple markdown preview - could be enhanced with a proper renderer */} <div
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre> className="preview-content"
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
/>
</div> </div>
)} )}
</div> </div>
{/* Lightbox for viewing images in content */}
<Lightbox
images={images}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</div> </div>
<div className="editor-footer"> <div className="editor-footer">

View File

@@ -0,0 +1,253 @@
/* Lightbox Overlay */
.lightbox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.lightbox-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Image Container */
.lightbox-image-container {
max-width: 90%;
max-height: 80%;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-image-container.zoomed {
max-width: 100%;
max-height: 100%;
overflow: auto;
cursor: zoom-out;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
cursor: zoom-in;
animation: scaleIn 0.2s ease-out;
}
.lightbox-image-container.zoomed .lightbox-image {
max-width: none;
max-height: none;
cursor: zoom-out;
}
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Close Button */
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background-color 0.2s;
z-index: 10;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Navigation Arrows */
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background-color 0.2s;
z-index: 10;
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.2);
}
.lightbox-prev {
left: 16px;
}
.lightbox-next {
right: 16px;
}
/* Footer */
.lightbox-footer {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
text-align: center;
color: white;
max-width: 80%;
}
.lightbox-caption {
font-size: 14px;
margin: 0 0 8px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.lightbox-counter {
font-size: 12px;
opacity: 0.7;
}
/* Thumbnails */
.lightbox-thumbnails {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
}
.lightbox-thumbnail {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, opacity 0.2s;
opacity: 0.6;
}
.lightbox-thumbnail:hover {
opacity: 0.9;
}
.lightbox-thumbnail.active {
border-color: white;
opacity: 1;
}
.lightbox-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Image Gallery Grid */
.image-gallery {
display: grid;
gap: 8px;
border-radius: 8px;
overflow: hidden;
}
.image-gallery.gallery-2 {
grid-template-columns: repeat(2, 1fr);
}
.image-gallery.gallery-3 {
grid-template-columns: repeat(3, 1fr);
}
.image-gallery.gallery-4 {
grid-template-columns: repeat(2, 1fr);
}
.gallery-item {
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.gallery-item:hover {
transform: scale(1.02);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Single Image */
.single-image {
cursor: pointer;
display: inline-block;
max-width: 100%;
}
.single-image img {
max-width: 100%;
border-radius: 6px;
transition: box-shadow 0.2s;
}
.single-image:hover img {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.image-caption {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-align: center;
margin-top: 8px;
}

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect, useCallback } from 'react';
import './Lightbox.css';
interface LightboxImage {
src: string;
alt?: string;
caption?: string;
}
interface LightboxProps {
images: LightboxImage[];
initialIndex?: number;
isOpen: boolean;
onClose: () => void;
}
export const Lightbox: React.FC<LightboxProps> = ({
images,
initialIndex = 0,
isOpen,
onClose,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isZoomed, setIsZoomed] = useState(false);
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
break;
case 'ArrowRight':
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
break;
}
}, [isOpen, images.length, onClose]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
if (!isOpen || images.length === 0) {
return null;
}
const currentImage = images[currentIndex];
const hasMultiple = images.length > 1;
const handlePrev = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const toggleZoom = () => {
setIsZoomed(!isZoomed);
};
return (
<div className="lightbox-overlay" onClick={handleBackdropClick}>
<div className="lightbox-container">
{/* Close button */}
<button className="lightbox-close" onClick={onClose} title="Close (Esc)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
{/* Navigation arrows */}
{hasMultiple && (
<>
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title="Previous (←)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button className="lightbox-nav lightbox-next" onClick={handleNext} title="Next (→)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
</>
)}
{/* Main image */}
<div className={`lightbox-image-container ${isZoomed ? 'zoomed' : ''}`}>
<img
src={currentImage.src}
alt={currentImage.alt || ''}
onClick={toggleZoom}
className="lightbox-image"
/>
</div>
{/* Caption and counter */}
<div className="lightbox-footer">
{currentImage.caption && (
<p className="lightbox-caption">{currentImage.caption}</p>
)}
{hasMultiple && (
<div className="lightbox-counter">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
{/* Thumbnail strip for galleries */}
{hasMultiple && images.length <= 10 && (
<div className="lightbox-thumbnails">
{images.map((image, index) => (
<button
key={index}
className={`lightbox-thumbnail ${index === currentIndex ? 'active' : ''}`}
onClick={() => setCurrentIndex(index)}
>
<img src={image.src} alt={image.alt || ''} />
</button>
))}
</div>
)}
</div>
</div>
);
};
// Hook to extract images from markdown content
export function useMarkdownImages(content: string): LightboxImage[] {
const [images, setImages] = useState<LightboxImage[]>([]);
useEffect(() => {
// Match markdown image syntax: ![alt](src)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches: LightboxImage[] = [];
let match;
while ((match = imageRegex.exec(content)) !== null) {
matches.push({
alt: match[1] || undefined,
src: match[2],
});
}
setImages(matches);
}, [content]);
return images;
}
// Component to render images with lightbox support
interface ImageGalleryProps {
images: LightboxImage[];
}
export const ImageGallery: React.FC<ImageGalleryProps> = ({ images }) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
if (images.length === 0) {
return null;
}
const openLightbox = (index: number) => {
setSelectedIndex(index);
setLightboxOpen(true);
};
if (images.length === 1) {
return (
<>
<div className="single-image" onClick={() => openLightbox(0)}>
<img src={images[0].src} alt={images[0].alt || ''} />
{images[0].caption && <p className="image-caption">{images[0].caption}</p>}
</div>
<Lightbox
images={images}
initialIndex={selectedIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</>
);
}
return (
<>
<div className={`image-gallery gallery-${Math.min(images.length, 4)}`}>
{images.map((image, index) => (
<div
key={index}
className="gallery-item"
onClick={() => openLightbox(index)}
>
<img src={image.src} alt={image.alt || ''} />
</div>
))}
</div>
<Lightbox
images={images}
initialIndex={selectedIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</>
);
};
export default Lightbox;

View File

@@ -0,0 +1 @@
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';

View File

@@ -0,0 +1,107 @@
.post-links {
margin-top: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background-color: var(--vscode-sideBar-background);
overflow: hidden;
}
.post-links-loading {
padding: 8px 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-links-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.post-links-toggle:hover {
background-color: var(--vscode-list-hoverBackground);
}
.post-links-icon {
font-size: 14px;
}
.post-links-count {
flex: 1;
text-align: left;
color: var(--vscode-descriptionForeground);
}
.post-links-chevron {
font-size: 10px;
color: var(--vscode-descriptionForeground);
transition: transform 0.15s;
}
.post-links-content {
border-top: 1px solid var(--vscode-panel-border);
padding: 8px 0;
}
.post-links-section {
padding: 4px 0;
}
.post-links-section:not(:last-child) {
border-bottom: 1px solid var(--vscode-panel-border);
margin-bottom: 4px;
padding-bottom: 8px;
}
.post-links-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 4px 0;
padding: 0 12px;
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-links-arrow {
font-size: 12px;
}
.post-links-list {
list-style: none;
margin: 0;
padding: 0;
}
.post-link-item {
display: block;
width: 100%;
padding: 4px 12px 4px 24px;
background: none;
border: none;
color: var(--vscode-textLink-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
transition: background-color 0.15s;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.post-link-item:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import './PostLinks.css';
interface PostLinkInfo {
id: string;
title: string;
slug: string;
}
interface PostLinksProps {
postId: string;
onPostClick?: (postId: string) => void;
}
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) => {
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const loadLinks = async () => {
setLoading(true);
try {
const [to, by] = await Promise.all([
window.electronAPI?.posts.getLinksTo(postId),
window.electronAPI?.posts.getLinkedBy(postId),
]);
setLinksTo(to || []);
setLinkedBy(by || []);
} catch (error) {
console.error('Failed to load post links:', error);
} finally {
setLoading(false);
}
};
loadLinks();
}, [postId]);
const totalLinks = linksTo.length + linkedBy.length;
if (loading) {
return (
<div className="post-links">
<div className="post-links-loading">Loading links...</div>
</div>
);
}
if (totalLinks === 0) {
return null;
}
return (
<div className="post-links">
<button
className={`post-links-toggle ${expanded ? 'expanded' : ''}`}
onClick={() => setExpanded(!expanded)}
>
<span className="post-links-icon">🔗</span>
<span className="post-links-count">{totalLinks} link{totalLinks !== 1 ? 's' : ''}</span>
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
</button>
{expanded && (
<div className="post-links-content">
{linksTo.length > 0 && (
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Links to ({linksTo.length})
</h4>
<ul className="post-links-list">
{linksTo.map(link => (
<li key={link.id}>
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
>
{link.title}
</button>
</li>
))}
</ul>
</div>
)}
{linkedBy.length > 0 && (
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Linked by ({linkedBy.length})
</h4>
<ul className="post-links-list">
{linkedBy.map(link => (
<li key={link.id}>
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
>
{link.title}
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
};
export default PostLinks;

View File

@@ -0,0 +1,2 @@
export { PostLinks } from './PostLinks';
export { default } from './PostLinks';

View File

@@ -0,0 +1,73 @@
.resizable-panel {
position: relative;
display: flex;
flex-shrink: 0;
}
.resizable-panel.horizontal {
flex-direction: row;
height: 100%;
}
.resizable-panel.vertical {
flex-direction: column;
width: 100%;
}
.resizable-panel.resizing {
pointer-events: none;
}
.resizable-panel.resizing .resizable-panel-content {
pointer-events: none;
}
.resizable-panel-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Resizer handle */
.resizer {
flex-shrink: 0;
background: transparent;
transition: background-color 0.15s;
z-index: 10;
}
.resizer.horizontal {
width: 4px;
cursor: col-resize;
}
.resizer.vertical {
height: 4px;
cursor: row-resize;
}
.resizer:hover,
.resizable-panel.resizing .resizer {
background-color: var(--vscode-sash-hoverBorder, #0078d4);
}
/* Double-click to reset */
.resizer::after {
content: '';
position: absolute;
}
.resizer.horizontal::after {
top: 0;
bottom: 0;
left: -2px;
right: -2px;
}
.resizer.vertical::after {
left: 0;
right: 0;
top: -2px;
bottom: -2px;
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import './ResizablePanel.css';
interface ResizablePanelProps {
children: React.ReactNode;
direction: 'horizontal' | 'vertical';
initialSize: number;
minSize?: number;
maxSize?: number;
storageKey?: string;
className?: string;
resizerPosition?: 'start' | 'end';
}
export const ResizablePanel: React.FC<ResizablePanelProps> = ({
children,
direction,
initialSize,
minSize = 150,
maxSize = 600,
storageKey,
className = '',
resizerPosition = 'end',
}) => {
// Load saved size from localStorage
const getSavedSize = () => {
if (storageKey) {
const saved = localStorage.getItem(`bds-panel-${storageKey}`);
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= minSize && parsed <= maxSize) {
return parsed;
}
}
}
return initialSize;
};
const [size, setSize] = useState(getSavedSize);
const [isResizing, setIsResizing] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const startPosRef = useRef(0);
const startSizeRef = useRef(0);
// Save size to localStorage
useEffect(() => {
if (storageKey && !isResizing) {
localStorage.setItem(`bds-panel-${storageKey}`, size.toString());
}
}, [size, storageKey, isResizing]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY;
startSizeRef.current = size;
}, [direction, size]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing) return;
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
let delta = currentPos - startPosRef.current;
// Reverse delta if resizer is at start
if (resizerPosition === 'start') {
delta = -delta;
}
const newSize = Math.max(minSize, Math.min(maxSize, startSizeRef.current + delta));
setSize(newSize);
}, [isResizing, direction, minSize, maxSize, resizerPosition]);
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing, handleMouseMove, handleMouseUp, direction]);
const style: React.CSSProperties = direction === 'horizontal'
? { width: size }
: { height: size };
return (
<div
ref={panelRef}
className={`resizable-panel ${direction} ${className} ${isResizing ? 'resizing' : ''}`}
style={style}
>
{resizerPosition === 'start' && (
<div
className={`resizer ${direction}`}
onMouseDown={handleMouseDown}
/>
)}
<div className="resizable-panel-content">
{children}
</div>
{resizerPosition === 'end' && (
<div
className={`resizer ${direction}`}
onMouseDown={handleMouseDown}
/>
)}
</div>
);
};
export default ResizablePanel;

View File

@@ -0,0 +1 @@
export { ResizablePanel } from './ResizablePanel';

View File

@@ -67,7 +67,10 @@
} }
.sidebar-item { .sidebar-item {
padding: 6px 12px 6px 24px; display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 12px 6px 12px;
cursor: pointer; cursor: pointer;
border-left: 2px solid transparent; border-left: 2px solid transparent;
} }
@@ -81,6 +84,39 @@
border-left-color: var(--vscode-focusBorder); border-left-color: var(--vscode-focusBorder);
} }
.post-type-icon {
font-size: 14px;
line-height: 1.4;
flex-shrink: 0;
opacity: 0.85;
}
.sidebar-item-content {
flex: 1;
min-width: 0;
}
/* Post type specific styling */
.sidebar-item.post-type-picture {
background: linear-gradient(90deg, rgba(139, 92, 246, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-aside {
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-quote {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-link {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-video {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 100%);
}
.sidebar-item-title { .sidebar-item-title {
font-size: 13px; font-size: 13px;
color: var(--vscode-sideBar-foreground); color: var(--vscode-sideBar-foreground);

View File

@@ -14,6 +14,28 @@ const formatFileSize = (bytes: number) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}; };
// Get post type icon based on categories
const getPostTypeIcon = (categories: string[]): { icon: string; type: string } => {
const lowerCategories = categories.map(c => c.toLowerCase());
if (lowerCategories.includes('picture') || lowerCategories.includes('photo') || lowerCategories.includes('image')) {
return { icon: '🖼️', type: 'picture' };
}
if (lowerCategories.includes('aside') || lowerCategories.includes('note') || lowerCategories.includes('quick')) {
return { icon: '📝', type: 'aside' };
}
if (lowerCategories.includes('link') || lowerCategories.includes('bookmark')) {
return { icon: '🔗', type: 'link' };
}
if (lowerCategories.includes('video')) {
return { icon: '🎬', type: 'video' };
}
if (lowerCategories.includes('quote')) {
return { icon: '💬', type: 'quote' };
}
// Default to article
return { icon: '📄', type: 'article' };
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface CalendarViewProps { interface CalendarViewProps {
@@ -399,16 +421,22 @@ const PostsList: React.FC = () => {
Drafts ({groupedPosts.draft.length}) Drafts ({groupedPosts.draft.length})
</div> </div>
<div className="sidebar-list"> <div className="sidebar-list">
{groupedPosts.draft.map(post => ( {groupedPosts.draft.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => setSelectedPost(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div> </div>
))} </div>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -420,16 +448,22 @@ const PostsList: React.FC = () => {
Published ({groupedPosts.published.length}) Published ({groupedPosts.published.length})
</div> </div>
<div className="sidebar-list"> <div className="sidebar-list">
{groupedPosts.published.map(post => ( {groupedPosts.published.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => setSelectedPost(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div> </div>
))} </div>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -441,16 +475,22 @@ const PostsList: React.FC = () => {
Archived ({groupedPosts.archived.length}) Archived ({groupedPosts.archived.length})
</div> </div>
<div className="sidebar-list"> <div className="sidebar-list">
{groupedPosts.archived.map(post => ( {groupedPosts.archived.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => setSelectedPost(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div> </div>
))} </div>
);
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,230 @@
.task-popup-wrapper {
position: relative;
}
.task-popup-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-statusBar-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.task-popup-trigger:hover {
background-color: var(--vscode-statusBarItem-hoverBackground);
}
.task-popup-trigger.active {
background-color: var(--vscode-statusBarItem-activeBackground);
}
.task-popup {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 8px;
width: 360px;
max-height: 400px;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
overflow: hidden;
animation: slideUp 0.15s ease-out;
z-index: 100;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.task-popup-header h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.task-popup-header .text-button {
background: none;
border: none;
color: var(--vscode-textLink-foreground);
font-size: 11px;
cursor: pointer;
padding: 0;
}
.task-popup-header .text-button:hover {
text-decoration: underline;
}
.task-section {
padding: 8px 0;
}
.task-section:not(:last-child) {
border-bottom: 1px solid var(--vscode-panel-border);
}
.task-section-title {
padding: 4px 16px 8px;
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
gap: 12px;
}
.task-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.task-item-info {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
.task-item-details {
flex: 1;
min-width: 0;
}
.task-item-message {
font-size: 12px;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-item-error {
font-size: 11px;
color: var(--vscode-notificationsErrorIcon-foreground);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress-bar {
height: 4px;
background-color: var(--vscode-progressBar-background);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-button-background);
border-radius: 2px;
transition: width 0.3s ease-out;
}
.task-cancel {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 12px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
}
.task-item:hover .task-cancel {
opacity: 1;
}
.task-cancel:hover {
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-notificationsErrorIcon-foreground);
}
.task-time {
font-size: 11px;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.task-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 12px;
flex-shrink: 0;
}
.task-icon.success {
color: var(--vscode-testing-iconPassed);
}
.task-icon.error {
color: var(--vscode-notificationsErrorIcon-foreground);
}
.task-icon.pending {
color: var(--vscode-descriptionForeground);
}
.task-icon.cancelled {
color: var(--vscode-descriptionForeground);
}
.task-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--vscode-panel-border);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.task-empty {
padding: 24px 16px;
text-align: center;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,190 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore } from '../../store';
import './TaskPopup.css';
export const TaskPopup: React.FC = () => {
const { tasks } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const popupRef = useRef<HTMLDivElement>(null);
const runningTasks = tasks.filter(t => t.status === 'running');
const pendingTasks = tasks.filter(t => t.status === 'pending');
const recentTasks = tasks
.filter(t => t.status === 'completed' || t.status === 'failed')
.sort((a, b) => {
const aTime = a.endTime ? new Date(a.endTime).getTime() : 0;
const bTime = b.endTime ? new Date(b.endTime).getTime() : 0;
return bTime - aTime;
})
.slice(0, 5);
const hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleCancel = async (taskId: string) => {
await window.electronAPI?.tasks.cancel(taskId);
};
const handleClearCompleted = async () => {
await window.electronAPI?.tasks.clearCompleted();
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <span className="task-spinner" />;
case 'completed':
return <span className="task-icon success"></span>;
case 'failed':
return <span className="task-icon error"></span>;
case 'pending':
return <span className="task-icon pending"></span>;
case 'cancelled':
return <span className="task-icon cancelled"></span>;
default:
return null;
}
};
if (!hasActiveTasks && recentTasks.length === 0) {
return null;
}
return (
<div className="task-popup-wrapper" ref={popupRef}>
<button
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
>
{runningTasks.length > 0 ? (
<>
<span className="task-spinner" />
<span>{runningTasks.length} running</span>
</>
) : pendingTasks.length > 0 ? (
<>
<span className="task-icon pending"></span>
<span>{pendingTasks.length} pending</span>
</>
) : (
<span>Tasks</span>
)}
</button>
{isOpen && (
<div className="task-popup">
<div className="task-popup-header">
<h4>Background Tasks</h4>
{recentTasks.length > 0 && (
<button className="text-button" onClick={handleClearCompleted}>
Clear completed
</button>
)}
</div>
{runningTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Running</div>
{runningTasks.map(task => (
<div key={task.taskId} className="task-item running">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
</div>
)}
{pendingTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Pending</div>
{pendingTasks.map(task => (
<div key={task.taskId} className="task-item pending">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
</div>
)}
{recentTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Recent</div>
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item ${task.status}`}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
))}
</div>
)}
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
<div className="task-empty">No active tasks</div>
)}
</div>
)}
</div>
);
};
export default TaskPopup;

View File

@@ -0,0 +1 @@
export { TaskPopup } from './TaskPopup';

View File

@@ -0,0 +1,296 @@
.wysiwyg-editor {
display: flex;
flex-direction: column;
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
}
/* Toolbar */
.wysiwyg-toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
flex-wrap: wrap;
gap: 4px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background-color: var(--vscode-panel-border);
margin: 0 8px;
}
.wysiwyg-toolbar button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.wysiwyg-toolbar button:hover:not(:disabled) {
background-color: var(--vscode-toolbar-hoverBackground);
}
.wysiwyg-toolbar button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.wysiwyg-toolbar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Editor Content */
.wysiwyg-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.wysiwyg-content .ProseMirror {
outline: none;
min-height: 100%;
color: var(--vscode-editor-foreground);
font-size: 15px;
line-height: 1.7;
}
.wysiwyg-content .ProseMirror > * + * {
margin-top: 0.75em;
}
/* Placeholder */
.wysiwyg-content .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--vscode-descriptionForeground);
pointer-events: none;
height: 0;
}
/* Typography */
.wysiwyg-content h1 {
font-size: 2em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content h2 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content h3 {
font-size: 1.25em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content p {
margin: 0.5em 0;
}
.wysiwyg-content strong {
font-weight: 600;
}
.wysiwyg-content em {
font-style: italic;
}
.wysiwyg-content u {
text-decoration: underline;
}
.wysiwyg-content s {
text-decoration: line-through;
}
/* Links */
.wysiwyg-content a,
.wysiwyg-content .editor-link {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
cursor: pointer;
}
.wysiwyg-content a:hover {
color: var(--vscode-textLink-activeForeground);
}
/* Lists */
.wysiwyg-content ul,
.wysiwyg-content ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.wysiwyg-content li {
margin: 0.25em 0;
}
.wysiwyg-content ul {
list-style-type: disc;
}
.wysiwyg-content ol {
list-style-type: decimal;
}
/* Blockquote */
.wysiwyg-content blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 1em;
margin: 1em 0;
color: var(--vscode-textBlockQuote-foreground);
font-style: italic;
}
/* Code */
.wysiwyg-content code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 0.9em;
}
.wysiwyg-content pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.wysiwyg-content pre code {
padding: 0;
background: none;
font-size: 0.9em;
line-height: 1.5;
}
/* Images */
.wysiwyg-content img,
.wysiwyg-content .editor-image {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 1em 0;
cursor: pointer;
transition: box-shadow 0.2s;
}
.wysiwyg-content img:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Horizontal Rule */
.wysiwyg-content hr {
border: none;
border-top: 1px solid var(--vscode-panel-border);
margin: 2em 0;
}
/* Bubble Menu */
.bubble-menu {
display: flex;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
padding: 4px;
gap: 2px;
}
.bubble-menu button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
cursor: pointer;
}
.bubble-menu button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.bubble-menu button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.bubble-menu .divider {
width: 1px;
background-color: var(--vscode-panel-border);
margin: 2px 4px;
}
/* Floating Menu */
.floating-menu {
display: flex;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
padding: 4px;
gap: 2px;
}
.floating-menu button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.floating-menu button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.floating-menu button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}

View File

@@ -0,0 +1,379 @@
import React, { useEffect, useCallback } from 'react';
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
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 TurndownService from 'turndown';
import './WysiwygEditor.css';
// Convert HTML to Markdown
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
});
// Add custom rules for turndown
turndownService.addRule('strikethrough', {
filter: ['del', 's', 'strike'],
replacement: (content) => `~~${content}~~`,
});
interface WysiwygEditorProps {
content: string;
onChange: (markdown: string) => void;
placeholder?: string;
}
// Simple markdown to HTML converter for TipTap
function markdownToHtml(markdown: string): string {
// Simple markdown parser - for production use a proper library
let html = markdown
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Strikethrough
.replace(/~~(.+?)~~/g, '<del>$1</del>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Blockquotes
.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
// Unordered lists
.replace(/^\s*[-*+] (.+)$/gim, '<li>$1</li>')
// Ordered lists
.replace(/^\d+\. (.+)$/gim, '<li>$1</li>')
// Horizontal rule
.replace(/^(?:---+|___+|\*\*\*+)$/gim, '<hr>')
// Paragraphs (double newlines)
.replace(/\n\n/g, '</p><p>')
// Single line breaks
.replace(/\n/g, '<br>');
// Wrap in paragraphs if not starting with a block element
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
// Fix consecutive blockquotes
html = html.replace(/<\/blockquote>\s*<blockquote>/g, '<br>');
// Wrap list items in ul/ol
html = html.replace(/(<li>.*<\/li>)+/g, (match) => {
// Check if it's ordered (starts with number) or unordered
return '<ul>' + match + '</ul>';
});
return html;
}
export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
content,
onChange,
placeholder = 'Start writing your content...',
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'editor-link',
},
}),
Image.configure({
HTMLAttributes: {
class: 'editor-image',
},
}),
Underline,
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const markdown = turndownService.turndown(html);
onChange(markdown);
},
});
useEffect(() => {
if (editor && content) {
const currentHtml = editor.getHTML();
const newHtml = markdownToHtml(content);
// Only update if content is significantly different
if (turndownService.turndown(currentHtml) !== content) {
editor.commands.setContent(newHtml);
}
}
}, [content]);
const addImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
if (url && editor) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
const setLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('Enter URL:', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
if (!editor) {
return null;
}
return (
<div className="wysiwyg-editor">
{/* Bubble menu appears when text is selected */}
{editor && (
<BubbleMenu className="bubble-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="Bold"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="Italic"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') ? 'is-active' : ''}
title="Underline"
>
<u>U</u>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
title="Strikethrough"
>
<s>S</s>
</button>
<div className="divider" />
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Link">
🔗
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive('code') ? 'is-active' : ''}
title="Code"
>
{'</>'}
</button>
</BubbleMenu>
)}
{/* Floating menu appears on empty lines */}
{editor && (
<FloatingMenu className="floating-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
>
H3
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
List
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'is-active' : ''}
>
Quote
</button>
<button onClick={addImage}>
🖼 Image
</button>
</FloatingMenu>
)}
{/* Toolbar */}
<div className="wysiwyg-toolbar">
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
title="Heading 1"
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
title="Heading 2"
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
title="Heading 3"
>
H3
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="Bold (Ctrl+B)"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="Italic (Ctrl+I)"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') ? 'is-active' : ''}
title="Underline (Ctrl+U)"
>
<u>U</u>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
title="Strikethrough"
>
<s>S</s>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
title="Bullet List"
>
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'is-active' : ''}
title="Numbered List"
>
1.
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'is-active' : ''}
title="Quote"
>
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive('codeBlock') ? 'is-active' : ''}
title="Code Block"
>
{'{}'}
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Insert Link">
🔗
</button>
<button onClick={addImage} title="Insert Image">
🖼
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
</button>
</div>
</div>
{/* Editor Content */}
<EditorContent className="wysiwyg-content" editor={editor} />
</div>
);
};
export default WysiwygEditor;

View File

@@ -0,0 +1 @@
export { WysiwygEditor } from './WysiwygEditor';

View File

@@ -5,3 +5,9 @@ export { StatusBar } from './StatusBar';
export { Panel } from './Panel'; export { Panel } from './Panel';
export { ToastContainer, toast, showToast, type ToastType } from './Toast'; export { ToastContainer, toast, showToast, type ToastType } from './Toast';
export { ProjectSelector } from './ProjectSelector'; export { ProjectSelector } from './ProjectSelector';
export { WysiwygEditor } from './WysiwygEditor';
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
export { TaskPopup } from './TaskPopup';
export { ResizablePanel } from './ResizablePanel';
export { CredentialsPanel } from './CredentialsPanel';
export { PostLinks } from './PostLinks';

View File

@@ -1,4 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Storage key for persisted state
const STORAGE_KEY = 'bds-app-state';
// Types // Types
export interface ProjectData { export interface ProjectData {
@@ -113,7 +117,9 @@ interface AppState {
setError: (error: string | null) => void; setError: (error: string | null) => void;
} }
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>()(
persist(
(set) => ({
// Initial Project State // Initial Project State
projects: [], projects: [],
activeProject: null, activeProject: null,
@@ -193,4 +199,17 @@ export const useAppStore = create<AppState>((set) => ({
// Loading Actions // Loading Actions
setLoading: (isLoading) => set({ isLoading }), setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
})); }),
{
name: STORAGE_KEY,
// Only persist UI state, not data (which is loaded from backend)
partialize: (state) => ({
activeView: state.activeView,
sidebarVisible: state.sidebarVisible,
panelVisible: state.panelVisible,
selectedPostId: state.selectedPostId,
selectedMediaId: state.selectedMediaId,
}),
}
)
);