initial commit
This commit is contained in:
200
src/main/database/connection.ts
Normal file
200
src/main/database/connection.ts
Normal 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;
|
||||
}
|
||||
2
src/main/database/index.ts
Normal file
2
src/main/database/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './connection';
|
||||
70
src/main/database/schema.ts
Normal file
70
src/main/database/schema.ts
Normal 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;
|
||||
442
src/main/engine/MediaEngine.ts
Normal file
442
src/main/engine/MediaEngine.ts
Normal 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;
|
||||
}
|
||||
386
src/main/engine/PostEngine.ts
Normal file
386
src/main/engine/PostEngine.ts
Normal 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;
|
||||
}
|
||||
324
src/main/engine/SyncEngine.ts
Normal file
324
src/main/engine/SyncEngine.ts
Normal 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;
|
||||
}
|
||||
166
src/main/engine/TaskManager.ts
Normal file
166
src/main/engine/TaskManager.ts
Normal 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
4
src/main/engine/index.ts
Normal 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
219
src/main/ipc/handlers.ts
Normal 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
1
src/main/ipc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerIpcHandlers } from './handlers';
|
||||
335
src/main/main.ts
Normal file
335
src/main/main.ts
Normal 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
118
src/main/preload.ts
Normal 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
20
src/renderer/App.css
Normal 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
263
src/renderer/App.tsx
Normal 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;
|
||||
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal file
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal 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;
|
||||
}
|
||||
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal file
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/ActivityBar/index.ts
Normal file
1
src/renderer/components/ActivityBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ActivityBar } from './ActivityBar';
|
||||
298
src/renderer/components/Editor/Editor.css
Normal file
298
src/renderer/components/Editor/Editor.css
Normal 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);
|
||||
}
|
||||
405
src/renderer/components/Editor/Editor.tsx
Normal file
405
src/renderer/components/Editor/Editor.tsx
Normal 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 />;
|
||||
};
|
||||
1
src/renderer/components/Editor/index.ts
Normal file
1
src/renderer/components/Editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Editor } from './Editor';
|
||||
155
src/renderer/components/Panel/Panel.css
Normal file
155
src/renderer/components/Panel/Panel.css
Normal 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); }
|
||||
}
|
||||
69
src/renderer/components/Panel/Panel.tsx
Normal file
69
src/renderer/components/Panel/Panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/Panel/index.ts
Normal file
1
src/renderer/components/Panel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Panel } from './Panel';
|
||||
203
src/renderer/components/Sidebar/Sidebar.css
Normal file
203
src/renderer/components/Sidebar/Sidebar.css
Normal 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;
|
||||
}
|
||||
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal file
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/Sidebar/index.ts
Normal file
1
src/renderer/components/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
91
src/renderer/components/StatusBar/StatusBar.css
Normal file
91
src/renderer/components/StatusBar/StatusBar.css
Normal 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;
|
||||
}
|
||||
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal file
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/StatusBar/index.ts
Normal file
1
src/renderer/components/StatusBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatusBar } from './StatusBar';
|
||||
5
src/renderer/components/index.ts
Normal file
5
src/renderer/components/index.ts
Normal 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
13
src/renderer/index.html
Normal 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
10
src/renderer/main.tsx
Normal 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>
|
||||
);
|
||||
160
src/renderer/store/appStore.ts
Normal file
160
src/renderer/store/appStore.ts
Normal 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 }),
|
||||
}));
|
||||
1
src/renderer/store/index.ts
Normal file
1
src/renderer/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||
286
src/renderer/styles/global.css
Normal file
286
src/renderer/styles/global.css
Normal 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
109
src/renderer/types/electron.d.ts
vendored
Normal 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 {};
|
||||
Reference in New Issue
Block a user