initial commit

This commit is contained in:
2026-02-10 11:04:44 +01:00
commit 5979fa3374
57 changed files with 19344 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
import { createClient, Client } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
import { app } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
export interface DatabaseConfig {
localPath: string;
tursoUrl?: string;
tursoAuthToken?: string;
}
type DrizzleDB = ReturnType<typeof drizzle>;
export class DatabaseConnection {
private localDb: DrizzleDB | null = null;
private remoteDb: DrizzleDB | null = null;
private localClient: Client | null = null;
private remoteClient: Client | null = null;
private config: DatabaseConfig;
constructor(config?: Partial<DatabaseConfig>) {
const userDataPath = app.getPath('userData');
this.config = {
localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
tursoUrl: config?.tursoUrl,
tursoAuthToken: config?.tursoAuthToken,
};
// Ensure user data directory exists
const dataDir = path.dirname(this.config.localPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Ensure posts and media directories exist
const postsDir = path.join(userDataPath, 'posts');
const mediaDir = path.join(userDataPath, 'media');
if (!fs.existsSync(postsDir)) {
fs.mkdirSync(postsDir, { recursive: true });
}
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
}
async initializeLocal(): Promise<DrizzleDB> {
if (this.localDb) {
return this.localDb;
}
// Use file: URL for local SQLite database via libsql
this.localClient = createClient({
url: `file:${this.config.localPath}`,
});
this.localDb = drizzle(this.localClient, { schema });
// Run migrations
await this.runMigrations();
return this.localDb;
}
async initializeRemote(): Promise<DrizzleDB | null> {
if (!this.config.tursoUrl || !this.config.tursoAuthToken) {
return null;
}
if (this.remoteDb) {
return this.remoteDb;
}
this.remoteClient = createClient({
url: this.config.tursoUrl,
authToken: this.config.tursoAuthToken,
});
this.remoteDb = drizzle(this.remoteClient, { schema });
return this.remoteDb;
}
getLocal(): DrizzleDB {
if (!this.localDb) {
throw new Error('Local database not initialized. Call initializeLocal() first.');
}
return this.localDb;
}
getRemote(): DrizzleDB | null {
return this.remoteDb;
}
private async runMigrations(): Promise<void> {
if (!this.localClient) return;
// Create tables if they don't exist using batch execution
await this.localClient.executeMultiple(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT
);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
alt TEXT,
caption TEXT,
file_path TEXT NOT NULL,
sidecar_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
timestamp INTEGER NOT NULL,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
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_sync_status ON posts(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);
`);
}
async close(): Promise<void> {
if (this.localClient) {
this.localClient.close();
this.localClient = null;
this.localDb = null;
}
if (this.remoteClient) {
this.remoteClient.close();
this.remoteClient = null;
this.remoteDb = null;
}
}
getDataPaths() {
const userDataPath = app.getPath('userData');
return {
database: this.config.localPath,
posts: path.join(userDataPath, 'posts'),
media: path.join(userDataPath, 'media'),
};
}
}
// Singleton instance
let dbConnection: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbConnection) {
dbConnection = new DatabaseConnection();
}
return dbConnection;
}
export function initDatabase(config?: Partial<DatabaseConfig>): DatabaseConnection {
dbConnection = new DatabaseConnection(config);
return dbConnection;
}

View File

@@ -0,0 +1,2 @@
export * from './schema';
export * from './connection';

View File

@@ -0,0 +1,70 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// Posts table - stores metadata for blog posts
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
author: text('author'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
});
// Media table - stores metadata for images and other media
export const media = sqliteTable('media', {
id: text('id').primaryKey(),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
alt: text('alt'),
caption: text('caption'),
filePath: text('file_path').notNull(),
sidecarPath: text('sidecar_path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
});
// Sync log - tracks sync operations
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
entityType: text('entity_type', { enum: ['post', 'media'] }).notNull(),
entityId: text('entity_id').notNull(),
operation: text('operation', { enum: ['create', 'update', 'delete'] }).notNull(),
status: text('status', { enum: ['pending', 'completed', 'failed'] }).notNull().default('pending'),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
errorMessage: text('error_message'),
retryCount: integer('retry_count').notNull().default(0),
});
// App settings - stores application configuration
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Types for TypeScript
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type SyncLogEntry = typeof syncLog.$inferSelect;
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;

View File

@@ -0,0 +1,442 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema';
import { taskManager, Task } from './TaskManager';
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: Date;
updatedAt: Date;
tags: string[];
}
export interface MediaMetadata {
id: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export class MediaEngine extends EventEmitter {
private mediaDir: string;
constructor() {
super();
this.mediaDir = getDatabase().getDataPaths().media;
}
private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('md5').update(buffer).digest('hex');
}
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
const sidecarPath = `${mediaPath}.meta`;
const metadata: MediaMetadata = {
id: mediaData.id,
originalName: mediaData.originalName,
mimeType: mediaData.mimeType,
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
alt: mediaData.alt,
caption: mediaData.caption,
createdAt: mediaData.createdAt.toISOString(),
updatedAt: mediaData.updatedAt.toISOString(),
tags: mediaData.tags,
};
// Write YAML-like format consistent with posts
const lines = [
'---',
`id: ${metadata.id}`,
`originalName: "${metadata.originalName}"`,
`mimeType: ${metadata.mimeType}`,
`size: ${metadata.size}`,
];
if (metadata.width) lines.push(`width: ${metadata.width}`);
if (metadata.height) lines.push(`height: ${metadata.height}`);
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
lines.push(`createdAt: ${metadata.createdAt}`);
lines.push(`updatedAt: ${metadata.updatedAt}`);
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
lines.push('---');
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
return sidecarPath;
}
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
try {
const content = await fs.readFile(sidecarPath, 'utf-8');
const lines = content.split('\n');
const metadata: Partial<MediaMetadata> = {
tags: [],
};
for (const line of lines) {
if (line === '---') continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// Remove quotes
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key) {
case 'id':
metadata.id = value;
break;
case 'originalName':
metadata.originalName = value;
break;
case 'mimeType':
metadata.mimeType = value;
break;
case 'size':
metadata.size = parseInt(value, 10);
break;
case 'width':
metadata.width = parseInt(value, 10);
break;
case 'height':
metadata.height = parseInt(value, 10);
break;
case 'alt':
metadata.alt = value;
break;
case 'caption':
metadata.caption = value;
break;
case 'createdAt':
metadata.createdAt = value;
break;
case 'updatedAt':
metadata.updatedAt = value;
break;
case 'tags':
// Parse array format: ["tag1", "tag2"]
const match = value.match(/\[(.*)\]/);
if (match) {
metadata.tags = match[1]
.split(',')
.map(t => t.trim().replace(/"/g, ''))
.filter(t => t.length > 0);
}
break;
}
}
if (!metadata.id || !metadata.originalName || !metadata.mimeType) {
return null;
}
return metadata as MediaMetadata;
} catch (error) {
console.error(`Failed to read sidecar file: ${sidecarPath}`, error);
return null;
}
}
private getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
};
return mimeTypes[ext] || 'application/octet-stream';
}
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
const db = getDatabase().getLocal();
const id = uuidv4();
const now = new Date();
const sourceBuffer = await fs.readFile(sourcePath);
const originalName = path.basename(sourcePath);
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const destPath = path.join(this.mediaDir, filename);
// Copy file to media directory
await fs.writeFile(destPath, sourceBuffer);
const mediaData: MediaData = {
id,
filename,
originalName,
mimeType: metadata?.mimeType || this.getMimeType(originalName),
size: sourceBuffer.length,
width: metadata?.width,
height: metadata?.height,
alt: metadata?.alt,
caption: metadata?.caption,
createdAt: now,
updatedAt: now,
tags: metadata?.tags || [],
};
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
const checksum = this.calculateChecksum(sourceBuffer);
const dbMedia: NewMedia = {
id: mediaData.id,
filename: mediaData.filename,
originalName: mediaData.originalName,
mimeType: mediaData.mimeType,
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
alt: mediaData.alt,
caption: mediaData.caption,
filePath: destPath,
sidecarPath,
createdAt: mediaData.createdAt,
updatedAt: mediaData.updatedAt,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(mediaData.tags),
};
await db.insert(media).values(dbMedia);
this.emit('mediaImported', mediaData);
return mediaData;
}
async updateMedia(id: string, data: Partial<MediaData>): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const existing = await this.getMedia(id);
if (!existing) {
return null;
}
const updated: MediaData = {
...existing,
...data,
id, // Ensure ID doesn't change
updatedAt: new Date(),
};
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) return null;
await this.writeSidecarFile(updated, dbMedia.filePath);
await db.update(media)
.set({
alt: updated.alt,
caption: updated.caption,
updatedAt: updated.updatedAt,
syncStatus: 'pending',
tags: JSON.stringify(updated.tags),
})
.where(eq(media.id, id));
this.emit('mediaUpdated', updated);
return updated;
}
async deleteMedia(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(media).where(eq(media.id, id)).get();
if (!existing) {
return false;
}
// Delete media file
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
}
// Delete sidecar file
try {
await fs.unlink(existing.sidecarPath);
} catch {
// File might not exist
}
await db.delete(media).where(eq(media.id, id));
this.emit('mediaDeleted', id);
return true;
}
async getMedia(id: string): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) {
return null;
}
return {
id: dbMedia.id,
filename: dbMedia.filename,
originalName: dbMedia.originalName,
mimeType: dbMedia.mimeType,
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
};
}
async getAllMedia(): Promise<MediaData[]> {
const db = getDatabase().getLocal();
const dbMediaList = await db.select().from(media).all();
return dbMediaList.map(dbMedia => ({
id: dbMedia.id,
filename: dbMedia.filename,
originalName: dbMedia.originalName,
mimeType: dbMedia.mimeType,
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
}));
}
getMediaPath(id: string): string {
return path.join(this.mediaDir, id);
}
async rebuildDatabaseFromFiles(): Promise<void> {
const task: Task<void> = {
id: uuidv4(),
name: 'Rebuild database from media files',
execute: async (onProgress) => {
const db = getDatabase().getLocal();
onProgress(0, 'Scanning media directory...');
const files = await fs.readdir(this.mediaDir);
const metaFiles = files.filter(f => f.endsWith('.meta'));
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
for (let i = 0; i < metaFiles.length; i++) {
const metaFile = metaFiles[i];
const sidecarPath = path.join(this.mediaDir, metaFile);
const mediaFilePath = sidecarPath.replace('.meta', '');
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
const metadata = await this.readSidecarFile(sidecarPath);
if (metadata) {
try {
const stats = await fs.stat(mediaFilePath);
const buffer = await fs.readFile(mediaFilePath);
const checksum = this.calculateChecksum(buffer);
const filename = path.basename(mediaFilePath);
const existing = await db.select().from(media).where(eq(media.id, metadata.id)).get();
if (existing) {
await db.update(media)
.set({
originalName: metadata.originalName,
mimeType: metadata.mimeType,
size: stats.size,
width: metadata.width,
height: metadata.height,
alt: metadata.alt,
caption: metadata.caption,
updatedAt: new Date(metadata.updatedAt),
checksum,
tags: JSON.stringify(metadata.tags),
})
.where(eq(media.id, metadata.id));
} else {
await db.insert(media).values({
id: metadata.id,
filename,
originalName: metadata.originalName,
mimeType: metadata.mimeType,
size: stats.size,
width: metadata.width,
height: metadata.height,
alt: metadata.alt,
caption: metadata.caption,
filePath: mediaFilePath,
sidecarPath,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
syncStatus: 'pending',
checksum,
tags: JSON.stringify(metadata.tags),
});
}
} catch (error) {
console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
};
await taskManager.runTask(task);
}
}
// Singleton instance
let mediaEngine: MediaEngine | null = null;
export function getMediaEngine(): MediaEngine {
if (!mediaEngine) {
mediaEngine = new MediaEngine();
}
return mediaEngine;
}

View File

@@ -0,0 +1,386 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import matter from 'gray-matter';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, Post, NewPost } from '../database/schema';
import { taskManager, Task } from './TaskManager';
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
tags: string[];
categories: string[];
}
export interface PostMetadata {
title: string;
slug: string;
excerpt?: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
id: string;
}
export class PostEngine extends EventEmitter {
private postsDir: string;
constructor() {
super();
this.postsDir = getDatabase().getDataPaths().posts;
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
private calculateChecksum(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
private async writePostFile(post: PostData): Promise<string> {
const metadata: PostMetadata = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
const fileContent = matter.stringify(post.content, metadata);
const filePath = path.join(this.postsDir, `${post.slug}.md`);
await fs.writeFile(filePath, fileContent, 'utf-8');
return filePath;
}
private async readPostFile(filePath: string): Promise<PostData | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const { data, content: body } = matter(content);
const metadata = data as PostMetadata;
return {
id: metadata.id,
title: metadata.title,
slug: metadata.slug,
excerpt: metadata.excerpt,
content: body,
status: metadata.status,
author: metadata.author,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,
tags: metadata.tags || [],
categories: metadata.categories || [],
};
} catch (error) {
console.error(`Failed to read post file: ${filePath}`, error);
return null;
}
}
async createPost(data: Partial<PostData>): Promise<PostData> {
const db = getDatabase().getLocal();
const now = new Date();
const id = uuidv4();
const slug = data.slug || this.generateSlug(data.title || 'untitled');
const post: PostData = {
id,
title: data.title || 'Untitled',
slug,
excerpt: data.excerpt,
content: data.content || '',
status: data.status || 'draft',
author: data.author,
createdAt: now,
updatedAt: now,
publishedAt: data.publishedAt,
tags: data.tags || [],
categories: data.categories || [],
};
// Write to filesystem first
const filePath = await this.writePostFile(post);
const checksum = this.calculateChecksum(post.content);
// Then update database
const dbPost: NewPost = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories),
};
await db.insert(posts).values(dbPost);
this.emit('postCreated', post);
return post;
}
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
const db = getDatabase().getLocal();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
const updated: PostData = {
...existing,
...data,
id, // Ensure ID doesn't change
updatedAt: new Date(),
};
// Handle slug change - need to rename file
if (data.slug && data.slug !== existing.slug) {
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
try {
await fs.unlink(oldPath);
} catch {
// Old file might not exist
}
}
const filePath = await this.writePostFile(updated);
const checksum = this.calculateChecksum(updated.content);
await db.update(posts)
.set({
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
status: updated.status,
author: updated.author,
updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories),
})
.where(eq(posts.id, id));
this.emit('postUpdated', updated);
return updated;
}
async deletePost(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!existing) {
return false;
}
// Delete file
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
}
// Delete from database
await db.delete(posts).where(eq(posts.id, id));
this.emit('postDeleted', id);
return true;
}
async getPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost) {
return null;
}
// Read content from file
const postData = await this.readPostFile(dbPost.filePath);
if (!postData) {
// File doesn't exist, reconstruct from database
return {
id: dbPost.id,
title: dbPost.title,
slug: dbPost.slug,
excerpt: dbPost.excerpt || undefined,
content: '',
status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined,
createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined,
tags: JSON.parse(dbPost.tags || '[]'),
categories: JSON.parse(dbPost.categories || '[]'),
};
}
return postData;
}
async getAllPosts(): Promise<PostData[]> {
const db = getDatabase().getLocal();
const dbPosts = await db.select().from(posts).all();
const result: PostData[] = [];
for (const dbPost of dbPosts) {
const postData = await this.getPost(dbPost.id);
if (postData) {
result.push(postData);
}
}
return result;
}
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
const db = getDatabase().getLocal();
const dbPosts = await db.select().from(posts).where(eq(posts.status, status)).all();
const result: PostData[] = [];
for (const dbPost of dbPosts) {
const postData = await this.getPost(dbPost.id);
if (postData) {
result.push(postData);
}
}
return result;
}
async publishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
status: 'published',
publishedAt: new Date(),
});
}
async unpublishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
status: 'draft',
publishedAt: undefined,
});
}
async rebuildDatabaseFromFiles(): Promise<void> {
const task: Task<void> = {
id: uuidv4(),
name: 'Rebuild database from post files',
execute: async (onProgress) => {
const db = getDatabase().getLocal();
onProgress(0, 'Scanning posts directory...');
const files = await fs.readdir(this.postsDir);
const mdFiles = files.filter(f => f.endsWith('.md'));
onProgress(10, `Found ${mdFiles.length} post files`);
for (let i = 0; i < mdFiles.length; i++) {
const file = mdFiles[i];
const filePath = path.join(this.postsDir, file);
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
const postData = await this.readPostFile(filePath);
if (postData) {
const existing = await db.select().from(posts).where(eq(posts.id, postData.id)).get();
const checksum = this.calculateChecksum(postData.content);
if (existing) {
await db.update(posts)
.set({
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
author: postData.author,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
} else {
await db.insert(posts).values({
id: postData.id,
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
author: postData.author,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
});
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
};
await taskManager.runTask(task);
}
}
// Singleton instance
let postEngine: PostEngine | null = null;
export function getPostEngine(): PostEngine {
if (!postEngine) {
postEngine = new PostEngine();
}
return postEngine;
}

View File

@@ -0,0 +1,324 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
import { taskManager, Task } from './TaskManager';
import { getPostEngine } from './PostEngine';
import { getMediaEngine } from './MediaEngine';
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
export type SyncStatus = 'idle' | 'syncing' | 'error';
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number; // in minutes
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export class SyncEngine extends EventEmitter {
private syncStatus: SyncStatus = 'idle';
private syncConfig: SyncConfig | null = null;
private syncIntervalId: NodeJS.Timeout | null = null;
constructor() {
super();
}
getSyncStatus(): SyncStatus {
return this.syncStatus;
}
isConfigured(): boolean {
return this.syncConfig !== null &&
!!this.syncConfig.tursoUrl &&
!!this.syncConfig.tursoAuthToken;
}
async configure(config: SyncConfig): Promise<void> {
this.syncConfig = config;
// Stop existing auto-sync
if (this.syncIntervalId) {
clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
// Start auto-sync if enabled
if (config.autoSync && config.syncInterval > 0) {
this.syncIntervalId = setInterval(
() => this.sync('bidirectional'),
config.syncInterval * 60 * 1000
);
}
// Initialize remote database connection
const db = getDatabase();
await db.initializeRemote();
this.emit('configured', config);
}
async sync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
if (!this.isConfigured()) {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync not configured'],
};
}
if (this.syncStatus === 'syncing') {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync already in progress'],
};
}
const task: Task<SyncResult> = {
id: uuidv4(),
name: `Sync (${direction})`,
execute: async (onProgress) => {
this.syncStatus = 'syncing';
this.emit('syncStarted', direction);
const result: SyncResult = {
success: true,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [],
};
try {
const db = getDatabase();
const localDb = db.getLocal();
const remoteDb = db.getRemote();
if (!remoteDb) {
throw new Error('Remote database not initialized');
}
onProgress(10, 'Fetching pending changes...');
if (direction === 'push' || direction === 'bidirectional') {
// Get pending posts
const pendingPosts = await localDb
.select()
.from(posts)
.where(eq(posts.syncStatus, 'pending'))
.all();
onProgress(20, `Pushing ${pendingPosts.length} posts...`);
for (const post of pendingPosts) {
try {
// Push to remote (simplified - in production would handle conflicts)
await remoteDb.insert(posts).values(post).onConflictDoUpdate({
target: posts.id,
set: {
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
checksum: post.checksum,
tags: post.tags,
categories: post.categories,
},
});
// Mark as synced locally
await localDb
.update(posts)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(posts.id, post.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push post ${post.id}: ${errorMsg}`);
// Log the error
await this.logSyncOperation(post.id, 'post', 'update', 'failed', errorMsg);
}
}
// Get pending media
const pendingMedia = await localDb
.select()
.from(media)
.where(eq(media.syncStatus, 'pending'))
.all();
onProgress(50, `Pushing ${pendingMedia.length} media items...`);
for (const item of pendingMedia) {
try {
await remoteDb.insert(media).values(item).onConflictDoUpdate({
target: media.id,
set: {
alt: item.alt,
caption: item.caption,
updatedAt: item.updatedAt,
checksum: item.checksum,
tags: item.tags,
},
});
await localDb
.update(media)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(media.id, item.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push media ${item.id}: ${errorMsg}`);
await this.logSyncOperation(item.id, 'media', 'update', 'failed', errorMsg);
}
}
}
if (direction === 'pull' || direction === 'bidirectional') {
onProgress(70, 'Pulling remote changes...');
// In a real implementation, we would:
// 1. Fetch all remote records with syncedAt > local last sync
// 2. Compare checksums to detect conflicts
// 3. Apply or merge changes
// For now, this is a placeholder
onProgress(90, 'Pull complete');
}
onProgress(100, 'Sync complete');
this.syncStatus = 'idle';
this.emit('syncCompleted', result);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.success = false;
result.errors.push(errorMsg);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return result;
}
},
};
return taskManager.runTask(task);
}
private async logSyncOperation(
entityId: string,
entityType: 'post' | 'media',
operation: 'create' | 'update' | 'delete',
status: 'pending' | 'completed' | 'failed',
errorMessage?: string
): Promise<void> {
const db = getDatabase().getLocal();
const logEntry: NewSyncLogEntry = {
id: uuidv4(),
entityType,
entityId,
operation,
status,
timestamp: new Date(),
errorMessage,
retryCount: 0,
};
await db.insert(syncLog).values(logEntry);
}
async getPendingChangesCount(): Promise<{ posts: number; media: number }> {
const db = getDatabase().getLocal();
const pendingPosts = await db
.select()
.from(posts)
.where(eq(posts.syncStatus, 'pending'))
.all();
const pendingMedia = await db
.select()
.from(media)
.where(eq(media.syncStatus, 'pending'))
.all();
return {
posts: pendingPosts.length,
media: pendingMedia.length,
};
}
async getSyncLog(limit = 50): Promise<Array<{
id: string;
entityType: string;
entityId: string;
operation: string;
status: string;
timestamp: Date;
errorMessage?: string;
}>> {
const db = getDatabase().getLocal();
const logs = await db
.select()
.from(syncLog)
.orderBy(syncLog.timestamp)
.limit(limit)
.all();
return logs.map(log => ({
id: log.id,
entityType: log.entityType,
entityId: log.entityId,
operation: log.operation,
status: log.status,
timestamp: log.timestamp,
errorMessage: log.errorMessage || undefined,
}));
}
stopAutoSync(): void {
if (this.syncIntervalId) {
clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
this.emit('autoSyncStopped');
}
}
// Singleton instance
let syncEngine: SyncEngine | null = null;
export function getSyncEngine(): SyncEngine {
if (!syncEngine) {
syncEngine = new SyncEngine();
}
return syncEngine;
}

View File

@@ -0,0 +1,166 @@
import { EventEmitter } from 'events';
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export interface TaskProgress {
taskId: string;
status: TaskStatus;
progress: number; // 0-100
message: string;
startTime: Date;
endTime?: Date;
error?: string;
}
export interface Task<T = unknown> {
id: string;
name: string;
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
cancel?: () => void;
}
export class TaskManager extends EventEmitter {
private tasks: Map<string, TaskProgress> = new Map();
private runningTasks: Map<string, AbortController> = new Map();
private maxConcurrentTasks = 3;
private taskQueue: Task[] = [];
constructor() {
super();
}
getTaskStatus(taskId: string): TaskProgress | undefined {
return this.tasks.get(taskId);
}
getAllTasks(): TaskProgress[] {
return Array.from(this.tasks.values());
}
getRunningTasks(): TaskProgress[] {
return Array.from(this.tasks.values()).filter(t => t.status === 'running');
}
async runTask<T>(task: Task<T>): Promise<T> {
const progress: TaskProgress = {
taskId: task.id,
status: 'pending',
progress: 0,
message: 'Waiting to start...',
startTime: new Date(),
};
this.tasks.set(task.id, progress);
this.emit('taskCreated', progress);
// Check if we can run immediately or need to queue
if (this.runningTasks.size >= this.maxConcurrentTasks) {
this.taskQueue.push(task as Task);
this.emit('taskQueued', progress);
// Wait until we can run
await new Promise<void>(resolve => {
const checkQueue = () => {
if (this.runningTasks.size < this.maxConcurrentTasks) {
const queueIndex = this.taskQueue.findIndex(t => t.id === task.id);
if (queueIndex !== -1) {
this.taskQueue.splice(queueIndex, 1);
}
resolve();
} else {
setTimeout(checkQueue, 100);
}
};
checkQueue();
});
}
const abortController = new AbortController();
this.runningTasks.set(task.id, abortController);
progress.status = 'running';
progress.message = 'Starting...';
this.emit('taskStarted', progress);
try {
const result = await task.execute((progressValue, message) => {
if (abortController.signal.aborted) {
throw new Error('Task cancelled');
}
progress.progress = progressValue;
progress.message = message;
this.emit('taskProgress', progress);
});
progress.status = 'completed';
progress.progress = 100;
progress.message = 'Completed';
progress.endTime = new Date();
this.emit('taskCompleted', progress);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage === 'Task cancelled') {
progress.status = 'cancelled';
progress.message = 'Cancelled';
} else {
progress.status = 'failed';
progress.error = errorMessage;
progress.message = `Failed: ${errorMessage}`;
}
progress.endTime = new Date();
this.emit('taskFailed', progress);
throw error;
} finally {
this.runningTasks.delete(task.id);
this.processQueue();
}
}
cancelTask(taskId: string): boolean {
const controller = this.runningTasks.get(taskId);
if (controller) {
controller.abort();
return true;
}
// Check if in queue
const queueIndex = this.taskQueue.findIndex(t => t.id === taskId);
if (queueIndex !== -1) {
this.taskQueue.splice(queueIndex, 1);
const progress = this.tasks.get(taskId);
if (progress) {
progress.status = 'cancelled';
progress.message = 'Cancelled (was queued)';
progress.endTime = new Date();
this.emit('taskCancelled', progress);
}
return true;
}
return false;
}
private processQueue(): void {
if (this.taskQueue.length > 0 && this.runningTasks.size < this.maxConcurrentTasks) {
// Queue processing happens automatically via the waiting promises
this.emit('queueProcessing');
}
}
clearCompletedTasks(): void {
for (const [id, task] of this.tasks) {
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
this.tasks.delete(id);
}
}
this.emit('tasksCleared');
}
}
// Singleton instance
export const taskManager = new TaskManager();

4
src/main/engine/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';

219
src/main/ipc/handlers.ts Normal file
View File

@@ -0,0 +1,219 @@
import { ipcMain, dialog, shell } from 'electron';
import { getPostEngine, PostData } from '../engine/PostEngine';
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
export function registerIpcHandlers(): void {
// ============ Post Handlers ============
ipcMain.handle('posts:create', async (_, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.createPost(data);
});
ipcMain.handle('posts:update', async (_, id: string, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.updatePost(id, data);
});
ipcMain.handle('posts:delete', async (_, id: string) => {
const engine = getPostEngine();
return engine.deletePost(id);
});
ipcMain.handle('posts:get', async (_, id: string) => {
const engine = getPostEngine();
return engine.getPost(id);
});
ipcMain.handle('posts:getAll', async () => {
const engine = getPostEngine();
return engine.getAllPosts();
});
ipcMain.handle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => {
const engine = getPostEngine();
return engine.getPostsByStatus(status);
});
ipcMain.handle('posts:publish', async (_, id: string) => {
const engine = getPostEngine();
return engine.publishPost(id);
});
ipcMain.handle('posts:unpublish', async (_, id: string) => {
const engine = getPostEngine();
return engine.unpublishPost(id);
});
ipcMain.handle('posts:rebuildFromFiles', async () => {
const engine = getPostEngine();
return engine.rebuildDatabaseFromFiles();
});
// ============ Media Handlers ============
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
const engine = getMediaEngine();
return engine.importMedia(sourcePath, metadata);
});
ipcMain.handle('media:importDialog', async () => {
const result = await dialog.showOpenDialog({
title: 'Import Media',
filters: [
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile', 'multiSelections'],
});
if (result.canceled || result.filePaths.length === 0) {
return [];
}
const engine = getMediaEngine();
const imported: MediaData[] = [];
for (const filePath of result.filePaths) {
try {
const media = await engine.importMedia(filePath);
imported.push(media);
} catch (error) {
console.error(`Failed to import ${filePath}:`, error);
}
}
return imported;
});
ipcMain.handle('media:update', async (_, id: string, data: Partial<MediaData>) => {
const engine = getMediaEngine();
return engine.updateMedia(id, data);
});
ipcMain.handle('media:delete', async (_, id: string) => {
const engine = getMediaEngine();
return engine.deleteMedia(id);
});
ipcMain.handle('media:get', async (_, id: string) => {
const engine = getMediaEngine();
return engine.getMedia(id);
});
ipcMain.handle('media:getAll', async () => {
const engine = getMediaEngine();
return engine.getAllMedia();
});
ipcMain.handle('media:rebuildFromFiles', async () => {
const engine = getMediaEngine();
return engine.rebuildDatabaseFromFiles();
});
// ============ Sync Handlers ============
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
const engine = getSyncEngine();
return engine.configure(config);
});
ipcMain.handle('sync:start', async (_, direction: SyncDirection = 'bidirectional') => {
const engine = getSyncEngine();
return engine.sync(direction);
});
ipcMain.handle('sync:getStatus', async () => {
const engine = getSyncEngine();
return engine.getSyncStatus();
});
ipcMain.handle('sync:isConfigured', async () => {
const engine = getSyncEngine();
return engine.isConfigured();
});
ipcMain.handle('sync:getPendingCount', async () => {
const engine = getSyncEngine();
return engine.getPendingChangesCount();
});
ipcMain.handle('sync:getLog', async (_, limit?: number) => {
const engine = getSyncEngine();
return engine.getSyncLog(limit);
});
ipcMain.handle('sync:stopAutoSync', async () => {
const engine = getSyncEngine();
return engine.stopAutoSync();
});
// ============ Task Handlers ============
ipcMain.handle('tasks:getAll', async () => {
return taskManager.getAllTasks();
});
ipcMain.handle('tasks:getRunning', async () => {
return taskManager.getRunningTasks();
});
ipcMain.handle('tasks:cancel', async (_, taskId: string) => {
return taskManager.cancelTask(taskId);
});
ipcMain.handle('tasks:clearCompleted', async () => {
return taskManager.clearCompletedTasks();
});
// ============ App Handlers ============
ipcMain.handle('app:getDataPaths', async () => {
return getDatabase().getDataPaths();
});
ipcMain.handle('app:openFolder', async (_, folderPath: string) => {
return shell.openPath(folderPath);
});
ipcMain.handle('app:showItemInFolder', async (_, itemPath: string) => {
return shell.showItemInFolder(itemPath);
});
// ============ Event Forwarding ============
// Forward engine events to renderer
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const syncEngine = getSyncEngine();
const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => {
// Will be sent to renderer via webContents when window is available
ipcMain.emit('forward-to-renderer', eventName, ...args);
};
};
postEngine.on('postCreated', forwardEvent('post:created'));
postEngine.on('postUpdated', forwardEvent('post:updated'));
postEngine.on('postDeleted', forwardEvent('post:deleted'));
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt'));
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
syncEngine.on('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
taskManager.on('taskCreated', forwardEvent('task:created'));
taskManager.on('taskStarted', forwardEvent('task:started'));
taskManager.on('taskProgress', forwardEvent('task:progress'));
taskManager.on('taskCompleted', forwardEvent('task:completed'));
taskManager.on('taskFailed', forwardEvent('task:failed'));
}

1
src/main/ipc/index.ts Normal file
View File

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

335
src/main/main.ts Normal file
View File

@@ -0,0 +1,335 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { getDatabase } from './database';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
// Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === 'development';
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'Blogging Desktop Server',
backgroundColor: '#1e1e1e', // VS Code dark background
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
icon: path.join(__dirname, '../../assets/icon.png'),
});
// Set up the application menu
const menu = createApplicationMenu();
Menu.setApplicationMenu(menu);
// Load the app - use built files unless explicitly in dev mode
const rendererPath = path.join(__dirname, '../renderer/index.html');
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else if (fs.existsSync(rendererPath)) {
mainWindow.loadFile(rendererPath);
} else {
// Fallback to dev server if built files don't exist
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
}
// Forward events to renderer
ipcMain.on('forward-to-renderer', (_event, eventName: string, ...args: unknown[]) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(eventName, ...args);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function createApplicationMenu(): Menu {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{
label: 'New Post',
accelerator: 'CmdOrCtrl+N',
click: () => {
mainWindow?.webContents.send('menu:newPost');
},
},
{
label: 'Import Media...',
accelerator: 'CmdOrCtrl+I',
click: () => {
mainWindow?.webContents.send('menu:importMedia');
},
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => {
mainWindow?.webContents.send('menu:save');
},
},
{ type: 'separator' },
{
label: 'Open Data Folder',
click: async () => {
const { shell } = require('electron');
const paths = getDatabase().getDataPaths();
shell.openPath(path.dirname(paths.database));
},
},
{ type: 'separator' },
{
label: 'Exit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4',
click: () => {
app.quit();
},
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Find',
accelerator: 'CmdOrCtrl+F',
click: () => {
mainWindow?.webContents.send('menu:find');
},
},
{
label: 'Replace',
accelerator: 'CmdOrCtrl+H',
click: () => {
mainWindow?.webContents.send('menu:replace');
},
},
],
},
{
label: 'View',
submenu: [
{
label: 'Posts',
accelerator: 'CmdOrCtrl+1',
click: () => {
mainWindow?.webContents.send('menu:viewPosts');
},
},
{
label: 'Media',
accelerator: 'CmdOrCtrl+2',
click: () => {
mainWindow?.webContents.send('menu:viewMedia');
},
},
{ type: 'separator' },
{
label: 'Toggle Sidebar',
accelerator: 'CmdOrCtrl+B',
click: () => {
mainWindow?.webContents.send('menu:toggleSidebar');
},
},
{
label: 'Toggle Panel',
accelerator: 'CmdOrCtrl+J',
click: () => {
mainWindow?.webContents.send('menu:togglePanel');
},
},
{ type: 'separator' },
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Blog',
submenu: [
{
label: 'Publish Selected',
accelerator: 'CmdOrCtrl+Shift+P',
click: () => {
mainWindow?.webContents.send('menu:publishSelected');
},
},
{
label: 'Unpublish Selected',
click: () => {
mainWindow?.webContents.send('menu:unpublishSelected');
},
},
{ type: 'separator' },
{
label: 'Preview Post',
accelerator: 'CmdOrCtrl+Shift+V',
click: () => {
mainWindow?.webContents.send('menu:previewPost');
},
},
{ type: 'separator' },
{
label: 'Rebuild Database from Files',
click: () => {
mainWindow?.webContents.send('menu:rebuildDatabase');
},
},
],
},
{
label: 'Sync',
submenu: [
{
label: 'Sync Now',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => {
mainWindow?.webContents.send('menu:syncNow');
},
},
{
label: 'Push Changes',
click: () => {
mainWindow?.webContents.send('menu:pushChanges');
},
},
{
label: 'Pull Changes',
click: () => {
mainWindow?.webContents.send('menu:pullChanges');
},
},
{ type: 'separator' },
{
label: 'Configure Sync...',
click: () => {
mainWindow?.webContents.send('menu:configureSync');
},
},
{
label: 'View Sync Log',
click: () => {
mainWindow?.webContents.send('menu:viewSyncLog');
},
},
],
},
{
label: 'Help',
submenu: [
{
label: 'About Blogging Desktop Server',
click: () => {
mainWindow?.webContents.send('menu:about');
},
},
{ type: 'separator' },
{
label: 'View on GitHub',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://github.com/bds/blogging-desktop-server');
},
},
{
label: 'Report Issue',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://github.com/bds/blogging-desktop-server/issues');
},
},
],
},
];
// macOS specific menu adjustments
if (process.platform === 'darwin') {
template.unshift({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
});
}
return Menu.buildFromTemplate(template);
}
async function initialize(): Promise<void> {
// Initialize database
const db = getDatabase();
await db.initializeLocal();
// Register IPC handlers
registerIpcHandlers();
}
// App lifecycle
app.whenReady().then(async () => {
await initialize();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async () => {
const db = getDatabase();
await db.close();
});
// Handle any uncaught exceptions
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
});

118
src/main/preload.ts Normal file
View File

@@ -0,0 +1,118 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
// Posts
posts: {
create: (data: unknown) => ipcRenderer.invoke('posts:create', data),
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getAll: () => ipcRenderer.invoke('posts:getAll'),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
},
// Media
media: {
import: (sourcePath: string, metadata?: unknown) => ipcRenderer.invoke('media:import', sourcePath, metadata),
importDialog: () => ipcRenderer.invoke('media:importDialog'),
update: (id: string, data: unknown) => ipcRenderer.invoke('media:update', id, data),
delete: (id: string) => ipcRenderer.invoke('media:delete', id),
get: (id: string) => ipcRenderer.invoke('media:get', id),
getAll: () => ipcRenderer.invoke('media:getAll'),
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
},
// Sync
sync: {
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
start: (direction?: string) => ipcRenderer.invoke('sync:start', direction),
getStatus: () => ipcRenderer.invoke('sync:getStatus'),
isConfigured: () => ipcRenderer.invoke('sync:isConfigured'),
getPendingCount: () => ipcRenderer.invoke('sync:getPendingCount'),
getLog: (limit?: number) => ipcRenderer.invoke('sync:getLog', limit),
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
},
// Tasks
tasks: {
getAll: () => ipcRenderer.invoke('tasks:getAll'),
getRunning: () => ipcRenderer.invoke('tasks:getRunning'),
cancel: (taskId: string) => ipcRenderer.invoke('tasks:cancel', taskId),
clearCompleted: () => ipcRenderer.invoke('tasks:clearCompleted'),
},
// App
app: {
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
},
// Event listeners
on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
once: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
},
});
// Type definitions for the exposed API
export interface ElectronAPI {
posts: {
create: (data: unknown) => Promise<unknown>;
update: (id: string, data: unknown) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<unknown>;
getAll: () => Promise<unknown[]>;
getByStatus: (status: string) => Promise<unknown[]>;
publish: (id: string) => Promise<unknown>;
unpublish: (id: string) => Promise<unknown>;
rebuildFromFiles: () => Promise<void>;
};
media: {
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
importDialog: () => Promise<unknown[]>;
update: (id: string, data: unknown) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<unknown>;
getAll: () => Promise<unknown[]>;
rebuildFromFiles: () => Promise<void>;
};
sync: {
configure: (config: unknown) => Promise<void>;
start: (direction?: string) => Promise<unknown>;
getStatus: () => Promise<string>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
};
tasks: {
getAll: () => Promise<unknown[]>;
getRunning: () => Promise<unknown[]>;
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

20
src/renderer/App.css Normal file
View File

@@ -0,0 +1,20 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}

263
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,263 @@
import React, { useEffect } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import './App.css';
const App: React.FC = () => {
const {
setPosts,
setMedia,
addPost,
updatePost,
removePost,
addMedia,
updateMedia,
removeMedia,
setTasks,
updateTask,
setSyncStatus,
setSyncConfigured,
setPendingChanges,
setLoading,
toggleSidebar,
togglePanel,
setActiveView,
setSelectedPost,
} = useAppStore();
// Load initial data
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// Load posts
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
// Load media
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
// Check sync status
const syncConfigured = await window.electronAPI?.sync.isConfigured();
setSyncConfigured(syncConfigured || false);
// Get pending changes count
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
// Load tasks
const tasks = await window.electronAPI?.tasks.getAll();
if (tasks) {
setTasks(tasks as TaskProgress[]);
}
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// Set up event listeners for real-time updates
useEffect(() => {
const unsubscribers: Array<() => void> = [];
// Post events
unsubscribers.push(
window.electronAPI?.on('post:created', (post: unknown) => {
addPost(post as PostData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:updated', (post: unknown) => {
const p = post as PostData;
updatePost(p.id, p);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:deleted', (id: unknown) => {
removePost(id as string);
}) || (() => {})
);
// Media events
unsubscribers.push(
window.electronAPI?.on('media:imported', (media: unknown) => {
addMedia(media as MediaData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:updated', (media: unknown) => {
const m = media as MediaData;
updateMedia(m.id, m);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:deleted', (id: unknown) => {
removeMedia(id as string);
}) || (() => {})
);
// Sync events
unsubscribers.push(
window.electronAPI?.on('sync:started', () => {
setSyncStatus('syncing');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:completed', async () => {
setSyncStatus('idle');
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:failed', () => {
setSyncStatus('error');
}) || (() => {})
);
// Task events
unsubscribers.push(
window.electronAPI?.on('task:progress', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:completed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:failed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
// Menu events
unsubscribers.push(
window.electronAPI?.on('menu:newPost', async () => {
const post = await window.electronAPI?.posts.create({
title: 'New Post',
content: '# New Post\n\nStart writing...',
});
if (post) {
setSelectedPost((post as PostData).id);
setActiveView('posts');
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:importMedia', () => {
window.electronAPI?.media.importDialog();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:toggleSidebar', () => {
toggleSidebar();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:togglePanel', () => {
togglePanel();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewPosts', () => {
setActiveView('posts');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewMedia', () => {
setActiveView('media');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:syncNow', () => {
window.electronAPI?.sync.start('bidirectional');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pushChanges', () => {
window.electronAPI?.sync.start('push');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pullChanges', () => {
window.electronAPI?.sync.start('pull');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:configureSync', () => {
setActiveView('settings');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:rebuildDatabase', async () => {
await window.electronAPI?.posts.rebuildFromFiles();
await window.electronAPI?.media.rebuildFromFiles();
// Reload data
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
}) || (() => {})
);
return () => {
unsubscribers.forEach(unsub => unsub());
};
}, []);
return (
<div className="app">
<div className="app-main">
<ActivityBar />
<Sidebar />
<div className="app-content">
<Editor />
<Panel />
</div>
</div>
<StatusBar />
</div>
);
};
export default App;

View File

@@ -0,0 +1,82 @@
.activity-bar {
width: 48px;
height: 100%;
background-color: var(--vscode-activityBar-background);
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 1px solid var(--vscode-panel-border);
}
.activity-bar-top,
.activity-bar-bottom {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
}
.activity-bar-item {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--vscode-activityBar-foreground);
opacity: 0.6;
cursor: pointer;
position: relative;
padding: 0;
border-radius: 0;
}
.activity-bar-item:hover {
opacity: 1;
background: transparent;
}
.activity-bar-item.active {
opacity: 1;
}
.activity-bar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--vscode-activityBar-foreground);
}
.activity-bar-item.syncing svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.activity-bar-badge {
position: absolute;
top: 8px;
right: 8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
background-color: var(--vscode-activityBarBadge-background);
color: var(--vscode-activityBarBadge-foreground);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { useAppStore } from '../../store';
import './ActivityBar.css';
// Simple SVG icons
const PostsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/>
<path d="M8 12h8v2H8zm0 4h8v2H8z"/>
</svg>
);
const MediaIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
);
const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
);
const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
);
export const ActivityBar: React.FC = () => {
const { activeView, setActiveView, syncStatus, pendingChanges } = useAppStore();
const totalPending = pendingChanges.posts + pendingChanges.media;
return (
<div className="activity-bar">
<div className="activity-bar-top">
<button
className={`activity-bar-item ${activeView === 'posts' ? 'active' : ''}`}
onClick={() => setActiveView('posts')}
title="Posts"
>
<PostsIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'media' ? 'active' : ''}`}
onClick={() => setActiveView('media')}
title="Media"
>
<MediaIcon />
</button>
</div>
<div className="activity-bar-bottom">
<button
className={`activity-bar-item ${syncStatus === 'syncing' ? 'syncing' : ''}`}
onClick={() => window.electronAPI?.sync.start()}
title={`Sync (${totalPending} pending)`}
>
<SyncIcon />
{totalPending > 0 && (
<span className="activity-bar-badge">{totalPending}</span>
)}
</button>
<button
className={`activity-bar-item ${activeView === 'settings' ? 'active' : ''}`}
onClick={() => setActiveView('settings')}
title="Settings"
>
<SettingsIcon />
</button>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,298 @@
.editor {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 35px;
background-color: var(--vscode-tab-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
}
.editor-tabs {
display: flex;
align-items: center;
gap: 2px;
}
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--vscode-tab-inactiveBackground);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
border-radius: 4px 4px 0 0;
}
.editor-tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground);
font-size: 10px;
}
.editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
.editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
overflow-y: auto;
gap: 16px;
}
.editor-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.editor-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 200px;
}
.editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-field input,
.editor-field textarea {
padding: 8px 10px;
border-radius: 4px;
}
.editor-field input.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.editor-field-row {
display: flex;
gap: 12px;
}
.editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 300px;
}
.editor-body label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-body textarea {
flex: 1;
resize: none;
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
line-height: 1.5;
padding: 12px;
border-radius: 4px;
}
.editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
}
/* Media Editor */
.media-editor {
flex-direction: row;
gap: 24px;
}
.media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
}
.media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
.media-details {
width: 320px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-details textarea {
resize: vertical;
}
/* Empty State / Welcome */
.editor-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-editor-background);
}
.welcome-content {
max-width: 600px;
text-align: center;
}
.welcome-content h1 {
font-size: 28px;
font-weight: 400;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-content > p {
margin-bottom: 40px;
}
.welcome-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 40px;
}
.welcome-action {
padding: 20px;
background-color: var(--vscode-sideBar-background);
border-radius: 8px;
text-align: left;
}
.welcome-action h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-action p {
font-size: 12px;
color: var(--vscode-descriptionForeground);
margin-bottom: 16px;
line-height: 1.5;
}
.welcome-action button {
width: 100%;
}
.welcome-shortcuts {
text-align: left;
background-color: var(--vscode-sideBar-background);
padding: 20px;
border-radius: 8px;
}
.welcome-shortcuts h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shortcut-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.shortcut-item kbd {
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
}
.shortcut-item span {
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store';
import './Editor.css';
interface PostEditorProps {
post: PostData;
}
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const { updatePost } = useAppStore();
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', '));
const [isDirty, setIsDirty] = useState(false);
// Reset when post changes
useEffect(() => {
setTitle(post.title);
setContent(post.content);
setTags(post.tags.join(', '));
setIsDirty(false);
}, [post.id]);
// Track changes
useEffect(() => {
const hasChanges =
title !== post.title ||
content !== post.content ||
tags !== post.tags.join(', ');
setIsDirty(hasChanges);
}, [title, content, tags, post]);
const handleSave = useCallback(async () => {
if (!isDirty) return;
try {
const updated = await window.electronAPI?.posts.update(post.id, {
title,
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
setIsDirty(false);
}
} catch (error) {
console.error('Failed to save post:', error);
}
}, [post.id, title, content, tags, isDirty, updatePost]);
const handlePublish = async () => {
await handleSave();
try {
const updated = await window.electronAPI?.posts.publish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to publish post:', error);
}
};
const handleUnpublish = async () => {
try {
const updated = await window.electronAPI?.posts.unpublish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to unpublish post:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await window.electronAPI?.posts.delete(post.id);
useAppStore.getState().removePost(post.id);
} catch (error) {
console.error('Failed to delete post:', error);
}
}
};
// 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);
const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish);
const unsubscribeUnpublish = window.electronAPI?.on('menu:unpublishSelected', handleUnpublish);
return () => {
unsubscribeSave?.();
unsubscribePublish?.();
unsubscribeUnpublish?.();
};
}, [handleSave]);
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
<span className="editor-tab-title">{post.title || 'Untitled'}</span>
{isDirty && <span className="editor-tab-dirty"></span>}
</div>
</div>
<div className="editor-actions">
<span className={`status-badge status-${post.status}`}>
{post.status}
</span>
{post.status === 'draft' ? (
<button onClick={handlePublish} title="Publish">Publish</button>
) : (
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
Unpublish
</button>
)}
<button onClick={handleSave} disabled={!isDirty} title="Save (Ctrl+S)">
Save
</button>
<button onClick={handleDelete} className="secondary danger" title="Delete">
Delete
</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="Post title"
/>
</div>
<div className="editor-field">
<label>Slug</label>
<input
type="text"
value={post.slug}
disabled
className="disabled"
/>
</div>
<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>
<div className="editor-body">
<label>Content (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post content in Markdown..."
spellCheck
/>
</div>
</div>
<div className="editor-footer">
<span className="text-muted text-small">
Created: {new Date(post.createdAt).toLocaleString()}
</span>
<span className="text-muted text-small">
Updated: {new Date(post.updatedAt).toLocaleString()}
</span>
{post.publishedAt && (
<span className="text-muted text-small">
Published: {new Date(post.publishedAt).toLocaleString()}
</span>
)}
</div>
</div>
);
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia } = useAppStore();
const item = media.find(m => m.id === mediaId);
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
useEffect(() => {
if (item) {
setAlt(item.alt || '');
setCaption(item.caption || '');
setTags(item.tags.join(', '));
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">Media not found</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
alt,
caption,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this media file?')) {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
} catch (error) {
console.error('Failed to delete media:', error);
}
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{item.originalName}</span>
</div>
</div>
<div className="editor-actions">
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
<span>{item.originalName}</span>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>File Name</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>Type</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Size</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>Dimensions</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>Alt Text</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder="Describe the image for accessibility"
/>
</div>
<div className="editor-field">
<label>Caption</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Image caption"
rows={3}
/>
</div>
<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>
</div>
</div>
);
};
const WelcomeScreen: React.FC = () => {
return (
<div className="editor-empty">
<div className="welcome-content">
<h1>Blogging Desktop Server</h1>
<p className="text-muted">bDS - Your offline-first blogging platform</p>
<div className="welcome-actions">
<div className="welcome-action">
<h3>Create a New Post</h3>
<p>Start writing your next blog post with Markdown support.</p>
<button onClick={() => window.electronAPI?.posts.create({ title: 'New Post' })}>
New Post
</button>
</div>
<div className="welcome-action">
<h3>Import Media</h3>
<p>Add images and files to use in your posts.</p>
<button className="secondary" onClick={() => window.electronAPI?.media.importDialog()}>
Import Media
</button>
</div>
<div className="welcome-action">
<h3>Configure Sync</h3>
<p>Connect to Turso for cloud synchronization.</p>
<button className="secondary" onClick={() => useAppStore.getState().setActiveView('settings')}>
Open Settings
</button>
</div>
</div>
<div className="welcome-shortcuts">
<h4>Keyboard Shortcuts</h4>
<div className="shortcut-list">
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>N</kbd>
<span>New Post</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>S</kbd>
<span>Save</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>B</kbd>
<span>Toggle Sidebar</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd>
<span>Publish</span>
</div>
</div>
</div>
</div>
</div>
);
};
export const Editor: React.FC = () => {
const { activeView, selectedPostId, selectedMediaId, posts } = useAppStore();
if (activeView === 'posts' && selectedPostId) {
const post = posts.find(p => p.id === selectedPostId);
if (post) {
return <PostEditor post={post} />;
}
}
if (activeView === 'media' && selectedMediaId) {
return <MediaEditor mediaId={selectedMediaId} />;
}
return <WelcomeScreen />;
};

View File

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

View File

@@ -0,0 +1,155 @@
.panel {
height: 200px;
display: flex;
flex-direction: column;
background-color: var(--vscode-panel-background);
border-top: 1px solid var(--vscode-panel-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 35px;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
}
.panel-close {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background-color: var(--vscode-sideBar-background);
border-radius: 4px;
font-size: 12px;
}
.task-status {
width: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.task-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-focusBorder);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.task-check {
color: var(--vscode-testing-iconPassed);
font-weight: bold;
}
.task-error {
color: var(--vscode-notificationsErrorIcon-foreground);
font-weight: bold;
}
.task-pending {
color: var(--vscode-descriptionForeground);
}
.task-info {
flex: 1;
min-width: 0;
}
.task-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-progress-bar {
height: 3px;
background-color: var(--vscode-input-background);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-focusBorder);
transition: width 0.2s ease;
}
.task-cancel {
padding: 2px 8px;
font-size: 11px;
background-color: var(--vscode-button-secondaryBackground);
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useAppStore } from '../../store';
import './Panel.css';
export const Panel: React.FC = () => {
const { panelVisible, tasks } = useAppStore();
if (!panelVisible) {
return null;
}
const recentTasks = tasks.slice(-10).reverse();
return (
<div className="panel">
<div className="panel-header">
<div className="panel-tabs">
<div className="panel-tab active">Tasks</div>
<div className="panel-tab">Output</div>
<div className="panel-tab">Sync Log</div>
</div>
<button
className="panel-close"
onClick={() => useAppStore.getState().togglePanel()}
title="Close Panel"
>
×
</button>
</div>
<div className="panel-content">
{recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,203 @@
.sidebar {
width: 280px;
height: 100%;
background-color: var(--vscode-sideBar-background);
border-right: 1px solid var(--vscode-sideBar-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-section {
margin-bottom: 4px;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-sideBar-foreground);
}
.sidebar-action {
background: transparent;
border: none;
padding: 2px;
color: var(--vscode-sideBar-foreground);
cursor: pointer;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
.sidebar-action:hover {
opacity: 1;
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-section-title {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.section-icon {
font-size: 8px;
}
.sidebar-list {
display: flex;
flex-direction: column;
}
.sidebar-item {
padding: 6px 12px 6px 24px;
cursor: pointer;
border-left: 2px solid transparent;
}
.sidebar-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
border-left-color: var(--vscode-focusBorder);
}
.sidebar-item-title {
font-size: 13px;
color: var(--vscode-sideBar-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.sidebar-empty p {
margin-bottom: 16px;
}
/* Media Grid */
.media-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2px;
padding: 4px;
}
.media-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
}
.media-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.media-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
.media-thumbnail {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 4px;
flex-shrink: 0;
}
.media-item-info {
flex: 1;
min-width: 0;
}
.media-item-name {
font-size: 12px;
color: var(--vscode-sideBar-foreground);
}
.media-item-size {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
/* Settings Panel */
.settings-panel {
padding: 0 12px 12px;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group h3 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-sideBar-foreground);
}
.settings-field {
margin-bottom: 12px;
}
.settings-field label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: var(--vscode-descriptionForeground);
}
.settings-field input {
width: 100%;
padding: 6px 8px;
}
.settings-group button {
width: 100%;
margin-bottom: 8px;
}
.settings-status {
font-size: 12px;
margin-top: 8px;
}

View File

@@ -0,0 +1,288 @@
import React from 'react';
import { useAppStore, PostData } from '../../store';
import './Sidebar.css';
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost } = useAppStore();
const handleCreatePost = async () => {
try {
const newPost = await window.electronAPI?.posts.create({
title: 'Untitled Post',
content: '# New Post\n\nStart writing your content here...',
});
if (newPost) {
setSelectedPost((newPost as PostData).id);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
const groupedPosts = {
draft: posts.filter(p => p.status === 'draft'),
published: posts.filter(p => p.status === 'published'),
archived: posts.filter(p => p.status === 'archived'),
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>POSTS</span>
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-draft"></span>
Drafts ({groupedPosts.draft.length})
</div>
<div className="sidebar-list">
{groupedPosts.draft.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{groupedPosts.published.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-published"></span>
Published ({groupedPosts.published.length})
</div>
<div className="sidebar-list">
{groupedPosts.published.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{groupedPosts.archived.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-archived"></span>
Archived ({groupedPosts.archived.length})
</div>
<div className="sidebar-list">
{groupedPosts.archived.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{posts.length === 0 && (
<div className="sidebar-empty">
<p>No posts yet</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
</div>
);
};
const MediaList: React.FC = () => {
const { media, selectedMediaId, setSelectedMedia } = useAppStore();
const handleImportMedia = async () => {
try {
await window.electronAPI?.media.importDialog();
} catch (error) {
console.error('Failed to import media:', error);
}
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>MEDIA</span>
<button className="sidebar-action" onClick={handleImportMedia} title="Import Media">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
<div className="sidebar-list media-grid">
{media.map(item => (
<div
key={item.id}
className={`media-item ${selectedMediaId === item.id ? 'selected' : ''}`}
onClick={() => setSelectedMedia(item.id)}
title={item.originalName}
>
{item.mimeType.startsWith('image/') ? (
<div className="media-thumbnail">
{/* Would load actual image in production */}
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
) : (
<div className="media-thumbnail">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
</div>
)}
<div className="media-item-info">
<div className="media-item-name truncate">{item.originalName}</div>
<div className="media-item-size">{formatFileSize(item.size)}</div>
</div>
</div>
))}
</div>
{media.length === 0 && (
<div className="sidebar-empty">
<p>No media files</p>
<button onClick={handleImportMedia}>Import media</button>
</div>
)}
</div>
);
};
const SettingsPanel: React.FC = () => {
const { syncConfigured } = useAppStore();
const [tursoUrl, setTursoUrl] = React.useState('');
const [tursoToken, setTursoToken] = React.useState('');
const handleSaveSync = async () => {
try {
await window.electronAPI?.sync.configure({
tursoUrl,
tursoAuthToken: tursoToken,
autoSync: true,
syncInterval: 5,
});
} catch (error) {
console.error('Failed to configure sync:', error);
}
};
return (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>SETTINGS</span>
</div>
</div>
<div className="settings-group">
<h3>Cloud Sync (Turso/LibSQL)</h3>
<div className="settings-field">
<label>Turso Database URL</label>
<input
type="text"
placeholder="libsql://your-db.turso.io"
value={tursoUrl}
onChange={(e) => setTursoUrl(e.target.value)}
/>
</div>
<div className="settings-field">
<label>Auth Token</label>
<input
type="password"
placeholder="Your auth token"
value={tursoToken}
onChange={(e) => setTursoToken(e.target.value)}
/>
</div>
<button onClick={handleSaveSync}>
{syncConfigured ? 'Update Sync Settings' : 'Enable Sync'}
</button>
{syncConfigured && (
<p className="settings-status status-published"> Sync is configured</p>
)}
</div>
<div className="settings-group">
<h3>Data Management</h3>
<button
className="secondary"
onClick={() => window.electronAPI?.posts.rebuildFromFiles()}
>
Rebuild Posts Database
</button>
<button
className="secondary"
onClick={() => window.electronAPI?.media.rebuildFromFiles()}
>
Rebuild Media Database
</button>
<button
className="secondary"
onClick={async () => {
const paths = await window.electronAPI?.app.getDataPaths();
if (paths) {
window.electronAPI?.app.openFolder(paths.posts);
}
}}
>
Open Data Folder
</button>
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
if (!sidebarVisible) {
return null;
}
return (
<div className="sidebar">
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsPanel />}
</div>
);
};

View File

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

View File

@@ -0,0 +1,91 @@
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-item.warning {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.sync-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--vscode-testing-iconPassed);
}
.sync-indicator.syncing {
animation: pulse 1s infinite;
background-color: var(--vscode-notificationsInfoIcon-foreground);
}
.sync-indicator.error {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.status-draft {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-dot.status-published {
background-color: var(--vscode-testing-iconPassed);
}
.status-dot.status-archived {
background-color: var(--vscode-descriptionForeground);
}
.status-bar-item.brand {
font-weight: 600;
letter-spacing: 0.5px;
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { useAppStore } from '../../store';
import './StatusBar.css';
export const StatusBar: React.FC = () => {
const {
syncStatus,
syncConfigured,
pendingChanges,
posts,
media,
tasks,
selectedPostId,
} = useAppStore();
const runningTasks = tasks.filter(t => t.status === 'running');
const totalPending = pendingChanges.posts + pendingChanges.media;
const selectedPost = posts.find(p => p.id === selectedPostId);
return (
<div className="status-bar">
<div className="status-bar-left">
{/* Sync Status */}
<div className={`status-bar-item ${!syncConfigured ? 'warning' : ''}`}>
<span className={`sync-indicator ${syncStatus}`} />
{!syncConfigured ? (
<span>Sync not configured</span>
) : syncStatus === 'syncing' ? (
<span>Syncing...</span>
) : totalPending > 0 ? (
<span>{totalPending} pending</span>
) : (
<span>Synced</span>
)}
</div>
{/* Running Tasks */}
{runningTasks.length > 0 && (
<div className="status-bar-item">
<span className="task-spinner" />
<span>{runningTasks[0].message}</span>
{runningTasks.length > 1 && (
<span className="text-muted">+{runningTasks.length - 1} more</span>
)}
</div>
)}
</div>
<div className="status-bar-right">
{/* Current Post Info */}
{selectedPost && (
<div className="status-bar-item">
<span className={`status-dot status-${selectedPost.status}`} />
<span>{selectedPost.status}</span>
</div>
)}
{/* Stats */}
<div className="status-bar-item">
<span>{posts.length} posts</span>
</div>
<div className="status-bar-item">
<span>{media.length} media</span>
</div>
{/* App Name */}
<div className="status-bar-item brand">
<span>bDS</span>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,5 @@
export { ActivityBar } from './ActivityBar';
export { Sidebar } from './Sidebar';
export { Editor } from './Editor';
export { StatusBar } from './StatusBar';
export { Panel } from './Panel';

13
src/renderer/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<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:;" />
<title>Blogging Desktop Server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,160 @@
import { create } from 'zustand';
// Types
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
// App State Store
interface AppState {
// UI State
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
selectedMediaId: string | null;
// Data
posts: PostData[];
media: MediaData[];
tasks: TaskProgress[];
// Sync
syncStatus: 'idle' | 'syncing' | 'error';
syncConfigured: boolean;
pendingChanges: { posts: number; media: number };
// Loading states
isLoading: boolean;
error: string | null;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
setPosts: (posts: PostData[]) => void;
addPost: (post: PostData) => void;
updatePost: (id: string, post: Partial<PostData>) => void;
removePost: (id: string) => void;
setMedia: (media: MediaData[]) => void;
addMedia: (media: MediaData) => void;
updateMedia: (id: string, media: Partial<MediaData>) => void;
removeMedia: (id: string) => void;
setTasks: (tasks: TaskProgress[]) => void;
updateTask: (taskId: string, task: Partial<TaskProgress>) => void;
setSyncStatus: (status: 'idle' | 'syncing' | 'error') => void;
setSyncConfigured: (configured: boolean) => void;
setPendingChanges: (changes: { posts: number; media: number }) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// Initial UI State
activeView: 'posts',
sidebarVisible: true,
panelVisible: false,
selectedPostId: null,
selectedMediaId: null,
// Initial Data
posts: [],
media: [],
tasks: [],
// Initial Sync State
syncStatus: 'idle',
syncConfigured: false,
pendingChanges: { posts: 0, media: 0 },
// Initial Loading State
isLoading: false,
error: null,
// UI Actions
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
setSelectedPost: (id) => set({ selectedPostId: id }),
setSelectedMedia: (id) => set({ selectedMediaId: id }),
// Post Actions
setPosts: (posts) => set({ posts }),
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
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,
})),
// Media Actions
setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
updateMedia: (id, updatedMedia) => set((state) => ({
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
})),
removeMedia: (id) => set((state) => ({
media: state.media.filter((m) => m.id !== id),
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
})),
// Task Actions
setTasks: (tasks) => set({ tasks }),
updateTask: (taskId, task) => set((state) => ({
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
})),
// Sync Actions
setSyncStatus: (syncStatus) => set({ syncStatus }),
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
}));

View File

@@ -0,0 +1 @@
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';

View File

@@ -0,0 +1,286 @@
/* VS Code-inspired CSS Variables and Global Styles */
:root {
/* Background colors */
--vscode-editor-background: #1e1e1e;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #3c3c3c;
--vscode-statusBar-background: #007acc;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-inactiveSelectionBackground: #37373d;
--vscode-input-background: #3c3c3c;
--vscode-dropdown-background: #3c3c3c;
--vscode-button-background: #0e639c;
--vscode-button-hoverBackground: #1177bb;
--vscode-button-secondaryBackground: #3a3d41;
/* Foreground colors */
--vscode-editor-foreground: #d4d4d4;
--vscode-sideBar-foreground: #cccccc;
--vscode-activityBar-foreground: #ffffff;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-descriptionForeground: #858585;
--vscode-button-foreground: #ffffff;
/* Border colors */
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-input-border: #3c3c3c;
--vscode-focusBorder: #007fd4;
/* Status colors */
--vscode-notificationsInfoIcon-foreground: #75beff;
--vscode-notificationsWarningIcon-foreground: #cca700;
--vscode-notificationsErrorIcon-foreground: #f48771;
--vscode-testing-iconPassed: #73c991;
--vscode-testing-iconFailed: #f14c4c;
/* Badge colors */
--vscode-badge-background: #4d4d4d;
--vscode-badge-foreground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
/* Scrollbar */
--vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4);
--vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
--vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
/* Font settings */
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-editor-font-family: 'Consolas', 'Courier New', monospace;
--vscode-font-size: 13px;
--vscode-editor-font-size: 14px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-editor-foreground);
background-color: var(--vscode-editor-background);
overflow: hidden;
user-select: none;
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-background);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-hoverBackground);
}
::-webkit-scrollbar-thumb:active {
background: var(--vscode-scrollbarSlider-activeBackground);
}
/* Input styles */
input, textarea, select {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 4px 8px;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--vscode-focusBorder);
}
input::placeholder, textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
/* Button styles */
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Icon button */
.icon-button {
background: transparent;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
}
.icon-button:hover {
background-color: var(--vscode-list-hoverBackground);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
font-size: 11px;
font-weight: 500;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
border-radius: 9px;
}
.badge.primary {
background-color: var(--vscode-activityBarBadge-background);
}
/* Status indicators */
.status-draft {
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-published {
color: var(--vscode-testing-iconPassed);
}
.status-archived {
color: var(--vscode-descriptionForeground);
}
/* Text styles */
.text-muted {
color: var(--vscode-descriptionForeground);
}
.text-small {
font-size: 11px;
}
/* Utility classes */
.flex {
display: flex;
}
.flex-1 {
flex: 1;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
.p-1 {
padding: 4px;
}
.p-2 {
padding: 8px;
}
.p-3 {
padding: 12px;
}
.px-2 {
padding-left: 8px;
padding-right: 8px;
}
.py-1 {
padding-top: 4px;
padding-bottom: 4px;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

109
src/renderer/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
// Type definitions for the Electron API exposed via preload
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export interface ElectronAPI {
posts: {
create: (data: Partial<PostData>) => Promise<PostData>;
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getAll: () => Promise<PostData[]>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
rebuildFromFiles: () => Promise<void>;
};
media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
importDialog: () => Promise<MediaData[]>;
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<MediaData | null>;
getAll: () => Promise<MediaData[]>;
rebuildFromFiles: () => Promise<void>;
};
sync: {
configure: (config: SyncConfig) => Promise<void>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
getStatus: () => Promise<'idle' | 'syncing' | 'error'>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
};
tasks: {
getAll: () => Promise<TaskProgress[]>;
getRunning: () => Promise<TaskProgress[]>;
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};