feat: next phase of basic work
This commit is contained in:
@@ -93,13 +93,49 @@ export class DatabaseConnection {
|
||||
return this.remoteDb;
|
||||
}
|
||||
|
||||
getLocalClient(): Client | null {
|
||||
return this.localClient;
|
||||
}
|
||||
|
||||
async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> {
|
||||
if (!this.localClient) return null;
|
||||
const result = await this.localClient.execute('SELECT id, name, slug FROM projects WHERE is_active = 1 LIMIT 1');
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
slug: row.slug as string,
|
||||
};
|
||||
}
|
||||
|
||||
async setActiveProject(projectId: string): Promise<void> {
|
||||
if (!this.localClient) return;
|
||||
await this.localClient.execute('UPDATE projects SET is_active = 0');
|
||||
await this.localClient.execute({
|
||||
sql: 'UPDATE projects SET is_active = 1 WHERE id = ?',
|
||||
args: [projectId],
|
||||
});
|
||||
}
|
||||
|
||||
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 projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
excerpt TEXT,
|
||||
@@ -118,6 +154,7 @@ export class DatabaseConnection {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
@@ -155,10 +192,36 @@ export class DatabaseConnection {
|
||||
|
||||
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_project_id ON posts(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||
`);
|
||||
|
||||
// Create FTS5 virtual table for full-text search
|
||||
await this.localClient.execute(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
id UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
excerpt,
|
||||
tags,
|
||||
categories,
|
||||
content_rowid=rowid
|
||||
);
|
||||
`);
|
||||
|
||||
// Create default project if none exists
|
||||
const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects');
|
||||
if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) {
|
||||
const now = Date.now();
|
||||
await this.localClient.execute({
|
||||
sql: 'INSERT INTO projects (id, name, slug, description, created_at, updated_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Projects table - stores blog projects/websites
|
||||
export const projects = sqliteTable('projects', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
description: text('description'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
// Posts table - stores metadata for blog posts
|
||||
export const posts = sqliteTable('posts', {
|
||||
projectId: text('project_id').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
@@ -21,6 +33,7 @@ export const posts = sqliteTable('posts', {
|
||||
|
||||
// Media table - stores metadata for images and other media
|
||||
export const media = sqliteTable('media', {
|
||||
projectId: text('project_id').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
filename: text('filename').notNull(),
|
||||
originalName: text('original_name').notNull(),
|
||||
@@ -60,6 +73,8 @@ export const settings = sqliteTable('settings', {
|
||||
});
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
export type Post = typeof posts.$inferSelect;
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
export type Media = typeof media.$inferSelect;
|
||||
|
||||
@@ -38,11 +38,25 @@ export interface MediaMetadata {
|
||||
}
|
||||
|
||||
export class MediaEngine extends EventEmitter {
|
||||
private mediaDir: string;
|
||||
private currentProjectId: string = 'default';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.mediaDir = getDatabase().getDataPaths().media;
|
||||
}
|
||||
|
||||
private getMediaDir(): string {
|
||||
const { app } = require('electron');
|
||||
const path = require('path');
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId, 'media');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
private calculateChecksum(buffer: Buffer): string {
|
||||
@@ -191,7 +205,9 @@ export class MediaEngine extends EventEmitter {
|
||||
const originalName = path.basename(sourcePath);
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `${id}${ext}`;
|
||||
const destPath = path.join(this.mediaDir, filename);
|
||||
const mediaDir = this.getMediaDir();
|
||||
await fs.mkdir(mediaDir, { recursive: true });
|
||||
const destPath = path.join(mediaDir, filename);
|
||||
|
||||
// Copy file to media directory
|
||||
await fs.writeFile(destPath, sourceBuffer);
|
||||
@@ -216,6 +232,7 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
const dbMedia: NewMedia = {
|
||||
id: mediaData.id,
|
||||
projectId: this.currentProjectId,
|
||||
filename: mediaData.filename,
|
||||
originalName: mediaData.originalName,
|
||||
mimeType: mediaData.mimeType,
|
||||
@@ -346,7 +363,7 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
getMediaPath(id: string): string {
|
||||
return path.join(this.mediaDir, id);
|
||||
return path.join(this.getMediaDir(), id);
|
||||
}
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
@@ -358,14 +375,20 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
onProgress(0, 'Scanning media directory...');
|
||||
|
||||
const files = await fs.readdir(this.mediaDir);
|
||||
const mediaDir = this.getMediaDir();
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(mediaDir);
|
||||
} catch {
|
||||
await fs.mkdir(mediaDir, { recursive: true });
|
||||
}
|
||||
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 sidecarPath = path.join(mediaDir, metaFile);
|
||||
const mediaFilePath = sidecarPath.replace('.meta', '');
|
||||
|
||||
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
|
||||
@@ -399,6 +422,7 @@ export class MediaEngine extends EventEmitter {
|
||||
} else {
|
||||
await db.insert(media).values({
|
||||
id: metadata.id,
|
||||
projectId: this.currentProjectId,
|
||||
filename,
|
||||
originalName: metadata.originalName,
|
||||
mimeType: metadata.mimeType,
|
||||
|
||||
@@ -4,13 +4,15 @@ 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 { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, Post, NewPost } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface PostData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
@@ -25,6 +27,8 @@ export interface PostData {
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
@@ -35,15 +39,45 @@ export interface PostMetadata {
|
||||
publishedAt?: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
matchSnippet?: string;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface PostFilter {
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}
|
||||
|
||||
export class PostEngine extends EventEmitter {
|
||||
private postsDir: string;
|
||||
private currentProjectId: string = 'default';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.postsDir = getDatabase().getDataPaths().posts;
|
||||
}
|
||||
|
||||
private getPostsDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId, 'posts');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
@@ -60,6 +94,7 @@ export class PostEngine extends EventEmitter {
|
||||
private async writePostFile(post: PostData): Promise<string> {
|
||||
const metadata: PostMetadata = {
|
||||
id: post.id,
|
||||
projectId: post.projectId,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
@@ -72,8 +107,11 @@ export class PostEngine extends EventEmitter {
|
||||
categories: post.categories,
|
||||
};
|
||||
|
||||
const postsDir = this.getPostsDir();
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
|
||||
const fileContent = matter.stringify(post.content, metadata);
|
||||
const filePath = path.join(this.postsDir, `${post.slug}.md`);
|
||||
const filePath = path.join(postsDir, `${post.slug}.md`);
|
||||
|
||||
await fs.writeFile(filePath, fileContent, 'utf-8');
|
||||
return filePath;
|
||||
@@ -87,6 +125,7 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
projectId: metadata.projectId || this.currentProjectId,
|
||||
title: metadata.title,
|
||||
slug: metadata.slug,
|
||||
excerpt: metadata.excerpt,
|
||||
@@ -107,12 +146,14 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const now = new Date();
|
||||
const id = uuidv4();
|
||||
const slug = data.slug || this.generateSlug(data.title || 'untitled');
|
||||
|
||||
const post: PostData = {
|
||||
id,
|
||||
projectId: data.projectId || this.currentProjectId,
|
||||
title: data.title || 'Untitled',
|
||||
slug,
|
||||
excerpt: data.excerpt,
|
||||
@@ -133,6 +174,7 @@ export class PostEngine extends EventEmitter {
|
||||
// Then update database
|
||||
const dbPost: NewPost = {
|
||||
id: post.id,
|
||||
projectId: post.projectId,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
@@ -150,12 +192,21 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
await db.insert(posts).values(dbPost);
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [post.id, post.title, post.content, post.excerpt || '', post.tags.join(' '), post.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('postCreated', post);
|
||||
return post;
|
||||
}
|
||||
|
||||
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const existing = await this.getPost(id);
|
||||
|
||||
if (!existing) {
|
||||
@@ -166,12 +217,14 @@ export class PostEngine extends EventEmitter {
|
||||
...existing,
|
||||
...data,
|
||||
id, // Ensure ID doesn't change
|
||||
projectId: existing.projectId, // Ensure projectId doesn't change
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Handle slug change - need to rename file
|
||||
const postsDir = this.getPostsDir();
|
||||
if (data.slug && data.slug !== existing.slug) {
|
||||
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
|
||||
const oldPath = path.join(postsDir, `${existing.slug}.md`);
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch {
|
||||
@@ -199,12 +252,22 @@ export class PostEngine extends EventEmitter {
|
||||
})
|
||||
.where(eq(posts.id, id));
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [updated.id, updated.title, updated.content, updated.excerpt || '', updated.tags.join(' '), updated.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('postUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<boolean> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
@@ -221,6 +284,11 @@ export class PostEngine extends EventEmitter {
|
||||
// Delete from database
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// Delete from FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||
}
|
||||
|
||||
this.emit('postDeleted', id);
|
||||
return true;
|
||||
}
|
||||
@@ -240,6 +308,7 @@ export class PostEngine extends EventEmitter {
|
||||
// File doesn't exist, reconstruct from database
|
||||
return {
|
||||
id: dbPost.id,
|
||||
projectId: dbPost.projectId,
|
||||
title: dbPost.title,
|
||||
slug: dbPost.slug,
|
||||
excerpt: dbPost.excerpt || undefined,
|
||||
@@ -259,7 +328,12 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async getAllPosts(): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPosts = await db.select().from(posts).all();
|
||||
const dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
@@ -275,7 +349,15 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
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 dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
eq(posts.status, status)
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
@@ -289,6 +371,140 @@ export class PostEngine extends EventEmitter {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const conditions = [eq(posts.projectId, this.currentProjectId)];
|
||||
|
||||
if (filter.status) {
|
||||
conditions.push(eq(posts.status, filter.status));
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
conditions.push(gte(posts.createdAt, filter.startDate));
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
conditions.push(lte(posts.createdAt, filter.endDate));
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
const startOfYear = new Date(filter.year, 0, 1);
|
||||
const endOfYear = new Date(filter.year + 1, 0, 1);
|
||||
conditions.push(gte(posts.createdAt, startOfYear));
|
||||
conditions.push(lte(posts.createdAt, endOfYear));
|
||||
}
|
||||
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
const startOfMonth = new Date(filter.year, filter.month, 1);
|
||||
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
||||
conditions.push(gte(posts.createdAt, startOfMonth));
|
||||
conditions.push(lte(posts.createdAt, endOfMonth));
|
||||
}
|
||||
|
||||
const dbPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
let result: PostData[] = [];
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
const postData = await this.getPost(dbPost.id);
|
||||
if (postData) {
|
||||
// Client-side filtering for tags/categories (JSON array)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag));
|
||||
if (!hasAllTags) continue;
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
const hasAnyCategory = filter.categories.some(cat => postData.categories.includes(cat));
|
||||
if (!hasAnyCategory) continue;
|
||||
}
|
||||
|
||||
result.push(postData);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchPosts(query: string): Promise<SearchResult[]> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id, title, excerpt, snippet(posts_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, rank
|
||||
FROM posts_fts
|
||||
WHERE posts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 50`,
|
||||
args: [query],
|
||||
});
|
||||
|
||||
const projectPosts = await this.getAllPosts();
|
||||
const projectPostIds = new Set(projectPosts.map(p => p.id));
|
||||
|
||||
return result.rows
|
||||
.filter(row => projectPostIds.has(row.id as string))
|
||||
.map(row => ({
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
slug: '', // Will be filled in by caller if needed
|
||||
excerpt: row.excerpt as string | undefined,
|
||||
matchSnippet: row.snippet as string | undefined,
|
||||
rank: row.rank as number | undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableTags(): Promise<string[]> {
|
||||
const allPosts = await this.getAllPosts();
|
||||
const tags = new Set<string>();
|
||||
for (const post of allPosts) {
|
||||
for (const tag of post.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
async getAvailableCategories(): Promise<string[]> {
|
||||
const allPosts = await this.getAllPosts();
|
||||
const categories = new Set<string>();
|
||||
for (const post of allPosts) {
|
||||
for (const cat of post.categories) {
|
||||
categories.add(cat);
|
||||
}
|
||||
}
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||
const allPosts = await this.getAllPosts();
|
||||
const counts = new Map<string, { year: number; month: number; count: number }>();
|
||||
|
||||
for (const post of allPosts) {
|
||||
const year = post.createdAt.getFullYear();
|
||||
const month = post.createdAt.getMonth();
|
||||
const key = `${year}-${month}`;
|
||||
const current = counts.get(key) || { year, month, count: 0 };
|
||||
current.count++;
|
||||
counts.set(key, current);
|
||||
}
|
||||
|
||||
return Array.from(counts.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return b.year - a.year;
|
||||
return b.month - a.month;
|
||||
});
|
||||
}
|
||||
|
||||
async publishPost(id: string): Promise<PostData | null> {
|
||||
return this.updatePost(id, {
|
||||
status: 'published',
|
||||
@@ -304,22 +520,30 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
const postsDir = this.getPostsDir();
|
||||
const task: Task<void> = {
|
||||
id: uuidv4(),
|
||||
name: 'Rebuild database from post files',
|
||||
execute: async (onProgress) => {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
|
||||
onProgress(0, 'Scanning posts directory...');
|
||||
|
||||
const files = await fs.readdir(this.postsDir);
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(postsDir);
|
||||
} catch {
|
||||
// Directory might not exist
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
}
|
||||
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);
|
||||
const filePath = path.join(postsDir, file);
|
||||
|
||||
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
|
||||
|
||||
@@ -348,6 +572,7 @@ export class PostEngine extends EventEmitter {
|
||||
} else {
|
||||
await db.insert(posts).values({
|
||||
id: postData.id,
|
||||
projectId: postData.projectId || this.currentProjectId,
|
||||
title: postData.title,
|
||||
slug: postData.slug,
|
||||
excerpt: postData.excerpt,
|
||||
@@ -363,6 +588,15 @@ export class PostEngine extends EventEmitter {
|
||||
categories: JSON.stringify(postData.categories),
|
||||
});
|
||||
}
|
||||
|
||||
// Update FTS index
|
||||
if (client) {
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [postData.id, postData.title, postData.content, postData.excerpt || '', postData.tags.join(' '), postData.categories.join(' ')],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
239
src/main/engine/ProjectEngine.ts
Normal file
239
src/main/engine/ProjectEngine.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { projects, Project, NewProject } from '../database/schema';
|
||||
|
||||
export interface ProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class ProjectEngine extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private async ensureProjectDirectories(slug: string): Promise<void> {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const projectDir = path.join(userDataPath, 'projects', slug);
|
||||
const postsDir = path.join(projectDir, 'posts');
|
||||
const mediaDir = path.join(projectDir, 'media');
|
||||
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
await fs.mkdir(mediaDir, { recursive: true });
|
||||
}
|
||||
|
||||
async createProject(data: { name: string; description?: string; slug?: string }): Promise<ProjectData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const now = new Date();
|
||||
const id = uuidv4();
|
||||
const slug = data.slug || this.generateSlug(data.name);
|
||||
|
||||
// Ensure unique slug
|
||||
let finalSlug = slug;
|
||||
let counter = 1;
|
||||
const existing = await db.select().from(projects).all();
|
||||
const existingSlugs = new Set(existing.map(p => p.slug));
|
||||
while (existingSlugs.has(finalSlug)) {
|
||||
finalSlug = `${slug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const project: ProjectData = {
|
||||
id,
|
||||
name: data.name,
|
||||
slug: finalSlug,
|
||||
description: data.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
// Create directories
|
||||
await this.ensureProjectDirectories(finalSlug);
|
||||
|
||||
// Insert into database
|
||||
const dbProject: NewProject = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
description: project.description,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
isActive: project.isActive,
|
||||
};
|
||||
|
||||
await db.insert(projects).values(dbProject);
|
||||
|
||||
this.emit('projectCreated', project);
|
||||
return project;
|
||||
}
|
||||
|
||||
async updateProject(id: string, data: Partial<Omit<ProjectData, 'id' | 'createdAt'>>): Promise<ProjectData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.update(projects)
|
||||
.set({
|
||||
name: updated.name,
|
||||
slug: updated.slug,
|
||||
description: updated.description,
|
||||
updatedAt: updated.updatedAt,
|
||||
isActive: updated.isActive,
|
||||
})
|
||||
.where(eq(projects.id, id));
|
||||
|
||||
const result: ProjectData = {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
slug: updated.slug,
|
||||
description: updated.description || undefined,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
isActive: updated.isActive ?? false,
|
||||
};
|
||||
|
||||
this.emit('projectUpdated', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteProject(id: string): Promise<boolean> {
|
||||
// Prevent deleting the default project
|
||||
if (id === 'default') {
|
||||
throw new Error('Cannot delete the default project');
|
||||
}
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Optionally delete project files (posts, media)
|
||||
// For safety, we'll leave them in place
|
||||
|
||||
await db.delete(projects).where(eq(projects.id, id));
|
||||
|
||||
this.emit('projectDeleted', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getProject(id: string): Promise<ProjectData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbProject = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
|
||||
if (!dbProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbProject.id,
|
||||
name: dbProject.name,
|
||||
slug: dbProject.slug,
|
||||
description: dbProject.description || undefined,
|
||||
createdAt: dbProject.createdAt,
|
||||
updatedAt: dbProject.updatedAt,
|
||||
isActive: dbProject.isActive ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllProjects(): Promise<ProjectData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbProjects = await db.select().from(projects).all();
|
||||
|
||||
return dbProjects.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
description: p.description || undefined,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
isActive: p.isActive ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
async getActiveProject(): Promise<ProjectData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbProject = await db.select().from(projects).where(eq(projects.isActive, true)).get();
|
||||
|
||||
if (!dbProject) {
|
||||
// Return default if no active project
|
||||
return this.getProject('default');
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbProject.id,
|
||||
name: dbProject.name,
|
||||
slug: dbProject.slug,
|
||||
description: dbProject.description || undefined,
|
||||
createdAt: dbProject.createdAt,
|
||||
updatedAt: dbProject.updatedAt,
|
||||
isActive: dbProject.isActive ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async setActiveProject(id: string): Promise<ProjectData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
// Deactivate all projects
|
||||
await db.update(projects).set({ isActive: false });
|
||||
|
||||
// Activate the selected project
|
||||
await db.update(projects)
|
||||
.set({ isActive: true })
|
||||
.where(eq(projects.id, id));
|
||||
|
||||
const project = await this.getProject(id);
|
||||
if (project) {
|
||||
this.emit('activeProjectChanged', project);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
getProjectPaths(projectSlug: string): { posts: string; media: string } {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return {
|
||||
posts: path.join(userDataPath, 'projects', projectSlug, 'posts'),
|
||||
media: path.join(userDataPath, 'projects', projectSlug, 'media'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let projectEngine: ProjectEngine | null = null;
|
||||
|
||||
export function getProjectEngine(): ProjectEngine {
|
||||
if (!projectEngine) {
|
||||
projectEngine = new ProjectEngine();
|
||||
}
|
||||
return projectEngine;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
||||
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
|
||||
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult } from './PostEngine';
|
||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
||||
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
||||
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
import { ipcMain, dialog, shell } from 'electron';
|
||||
import { getPostEngine, PostData } from '../engine/PostEngine';
|
||||
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Project Handlers ============
|
||||
|
||||
ipcMain.handle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.createProject(data);
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:update', async (_, id: string, data: Partial<ProjectData>) => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.updateProject(id, data);
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:delete', async (_, id: string) => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.deleteProject(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:get', async (_, id: string) => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.getProject(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:getAll', async () => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.getAllProjects();
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:getActive', async () => {
|
||||
const engine = getProjectEngine();
|
||||
return engine.getActiveProject();
|
||||
});
|
||||
|
||||
ipcMain.handle('projects:setActive', async (_, id: string) => {
|
||||
const projectEngine = getProjectEngine();
|
||||
const project = await projectEngine.setActiveProject(id);
|
||||
|
||||
// Update post and media engines to use the new project context
|
||||
if (project) {
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
postEngine.setProjectContext(project.id);
|
||||
mediaEngine.setProjectContext(project.id);
|
||||
}
|
||||
|
||||
return project;
|
||||
});
|
||||
|
||||
// ============ Post Handlers ============
|
||||
|
||||
ipcMain.handle('posts:create', async (_, data: Partial<PostData>) => {
|
||||
@@ -53,6 +101,31 @@ export function registerIpcHandlers(): void {
|
||||
return engine.rebuildDatabaseFromFiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:search', async (_, query: string) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.searchPosts(query);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:filter', async (_, filter: PostFilter) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getPostsFiltered(filter);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getTags', async () => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getAvailableTags();
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getCategories', async () => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getAvailableCategories();
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getByYearMonth', async () => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getPostsByYearMonth();
|
||||
});
|
||||
|
||||
// ============ Media Handlers ============
|
||||
|
||||
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
||||
@@ -189,6 +262,7 @@ export function registerIpcHandlers(): void {
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
const syncEngine = getSyncEngine();
|
||||
const projectEngine = getProjectEngine();
|
||||
|
||||
const forwardEvent = (eventName: string) => {
|
||||
return (...args: unknown[]) => {
|
||||
@@ -197,6 +271,11 @@ export function registerIpcHandlers(): void {
|
||||
};
|
||||
};
|
||||
|
||||
projectEngine.on('projectCreated', forwardEvent('project:created'));
|
||||
projectEngine.on('projectUpdated', forwardEvent('project:updated'));
|
||||
projectEngine.on('projectDeleted', forwardEvent('project:deleted'));
|
||||
projectEngine.on('activeProjectChanged', forwardEvent('project:activeChanged'));
|
||||
|
||||
postEngine.on('postCreated', forwardEvent('post:created'));
|
||||
postEngine.on('postUpdated', forwardEvent('post:updated'));
|
||||
postEngine.on('postDeleted', forwardEvent('post:deleted'));
|
||||
|
||||
@@ -3,6 +3,17 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Projects
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||
update: (id: string, data: unknown) => ipcRenderer.invoke('projects:update', id, data),
|
||||
delete: (id: string) => ipcRenderer.invoke('projects:delete', id),
|
||||
get: (id: string) => ipcRenderer.invoke('projects:get', id),
|
||||
getAll: () => ipcRenderer.invoke('projects:getAll'),
|
||||
getActive: () => ipcRenderer.invoke('projects:getActive'),
|
||||
setActive: (id: string) => ipcRenderer.invoke('projects:setActive', id),
|
||||
},
|
||||
|
||||
// Posts
|
||||
posts: {
|
||||
create: (data: unknown) => ipcRenderer.invoke('posts:create', data),
|
||||
@@ -14,6 +25,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
||||
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
|
||||
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
|
||||
search: (query: string) => ipcRenderer.invoke('posts:search', query),
|
||||
filter: (filter: unknown) => ipcRenderer.invoke('posts:filter', filter),
|
||||
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
||||
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
||||
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
||||
},
|
||||
|
||||
// Media
|
||||
@@ -67,6 +83,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// Type definitions for the exposed API
|
||||
export interface ElectronAPI {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<unknown>;
|
||||
update: (id: string, data: unknown) => Promise<unknown>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<unknown>;
|
||||
getAll: () => Promise<unknown[]>;
|
||||
getActive: () => Promise<unknown>;
|
||||
setActive: (id: string) => Promise<unknown>;
|
||||
};
|
||||
posts: {
|
||||
create: (data: unknown) => Promise<unknown>;
|
||||
update: (id: string, data: unknown) => Promise<unknown>;
|
||||
@@ -77,6 +102,11 @@ export interface ElectronAPI {
|
||||
publish: (id: string) => Promise<unknown>;
|
||||
unpublish: (id: string) => Promise<unknown>;
|
||||
rebuildFromFiles: () => Promise<void>;
|
||||
search: (query: string) => Promise<unknown[]>;
|
||||
filter: (filter: unknown) => Promise<unknown[]>;
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, ToastContainer, showToast } from './components';
|
||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||
import './App.css';
|
||||
|
||||
@@ -115,12 +115,15 @@ const App: React.FC = () => {
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('sync:started', () => {
|
||||
setSyncStatus('syncing');
|
||||
showToast.loading('Syncing...');
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('sync:completed', async () => {
|
||||
setSyncStatus('idle');
|
||||
showToast.dismiss();
|
||||
showToast.success('Sync completed');
|
||||
const pending = await window.electronAPI?.sync.getPendingCount();
|
||||
if (pending) {
|
||||
setPendingChanges(pending);
|
||||
@@ -131,6 +134,8 @@ const App: React.FC = () => {
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('sync:failed', () => {
|
||||
setSyncStatus('error');
|
||||
showToast.dismiss();
|
||||
showToast.error('Sync failed');
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
@@ -146,6 +151,7 @@ const App: React.FC = () => {
|
||||
window.electronAPI?.on('task:completed', (task: unknown) => {
|
||||
const t = task as TaskProgress;
|
||||
updateTask(t.taskId, t);
|
||||
showToast.success(`Task completed: ${t.message}`);
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
@@ -153,6 +159,7 @@ const App: React.FC = () => {
|
||||
window.electronAPI?.on('task:failed', (task: unknown) => {
|
||||
const t = task as TaskProgress;
|
||||
updateTask(t.taskId, t);
|
||||
showToast.error(`Task failed: ${t.error || t.message}`);
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
@@ -256,6 +263,7 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -154,6 +154,58 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor-mode-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-mode-toggle button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.editor-mode-toggle button:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.editor-mode-toggle button.active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
flex: 1;
|
||||
background-color: var(--vscode-input-background);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.editor-field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-field-row .editor-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import './Editor.css';
|
||||
|
||||
interface PostEditorProps {
|
||||
@@ -11,13 +13,17 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const [title, setTitle] = useState(post.title);
|
||||
const [content, setContent] = useState(post.content);
|
||||
const [tags, setTags] = useState(post.tags.join(', '));
|
||||
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown');
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
// Reset when post changes
|
||||
useEffect(() => {
|
||||
setTitle(post.title);
|
||||
setContent(post.content);
|
||||
setTags(post.tags.join(', '));
|
||||
setCategories(post.categories.join(', '));
|
||||
setIsDirty(false);
|
||||
}, [post.id]);
|
||||
|
||||
@@ -26,9 +32,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const hasChanges =
|
||||
title !== post.title ||
|
||||
content !== post.content ||
|
||||
tags !== post.tags.join(', ');
|
||||
tags !== post.tags.join(', ') ||
|
||||
categories !== post.categories.join(', ');
|
||||
setIsDirty(hasChanges);
|
||||
}, [title, content, tags, post]);
|
||||
}, [title, content, tags, categories, post]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isDirty) return;
|
||||
@@ -38,16 +45,19 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
title,
|
||||
content,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
updatePost(post.id, updated as Partial<PostData>);
|
||||
setIsDirty(false);
|
||||
showToast.success('Post saved');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save post:', error);
|
||||
showToast.error('Failed to save post');
|
||||
}
|
||||
}, [post.id, title, content, tags, isDirty, updatePost]);
|
||||
}, [post.id, title, content, tags, categories, isDirty, updatePost]);
|
||||
|
||||
const handlePublish = async () => {
|
||||
await handleSave();
|
||||
@@ -55,9 +65,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const updated = await window.electronAPI?.posts.publish(post.id);
|
||||
if (updated) {
|
||||
updatePost(post.id, updated as Partial<PostData>);
|
||||
showToast.success('Post published');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish post:', error);
|
||||
showToast.error('Failed to publish post');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,9 +78,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const updated = await window.electronAPI?.posts.unpublish(post.id);
|
||||
if (updated) {
|
||||
updatePost(post.id, updated as Partial<PostData>);
|
||||
showToast.success('Post unpublished');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unpublish post:', error);
|
||||
showToast.error('Failed to unpublish post');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,12 +91,20 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
try {
|
||||
await window.electronAPI?.posts.delete(post.id);
|
||||
useAppStore.getState().removePost(post.id);
|
||||
useAppStore.getState().setSelectedPost(null);
|
||||
showToast.success('Post deleted');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete post:', error);
|
||||
showToast.error('Failed to delete post');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Monaco editor mount
|
||||
const handleEditorDidMount = (editor: unknown) => {
|
||||
editorRef.current = editor;
|
||||
};
|
||||
|
||||
// Save on Ctrl+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -158,25 +180,76 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
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 className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Categories (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={categories}
|
||||
onChange={(e) => setCategories(e.target.value)}
|
||||
placeholder="category1, category2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
<label>Content (Markdown)</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write your post content in Markdown..."
|
||||
spellCheck
|
||||
/>
|
||||
<div className="editor-toolbar">
|
||||
<label>Content (Markdown)</label>
|
||||
<div className="editor-mode-toggle">
|
||||
<button
|
||||
className={editorMode === 'markdown' ? 'active' : ''}
|
||||
onClick={() => setEditorMode('markdown')}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'preview' ? 'active' : ''}
|
||||
onClick={() => setEditorMode('preview')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{editorMode === 'markdown' ? (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
value={content}
|
||||
onChange={(value) => setContent(value || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||
padding: { top: 12, bottom: 12 },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'line',
|
||||
quickSuggestions: false,
|
||||
formatOnPaste: true,
|
||||
cursorStyle: 'line',
|
||||
cursorBlinking: 'smooth',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-preview markdown-body">
|
||||
{/* Simple markdown preview - could be enhanced with a proper renderer */}
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
277
src/renderer/components/ProjectSelector/ProjectSelector.css
Normal file
277
src/renderer/components/ProjectSelector/ProjectSelector.css
Normal file
@@ -0,0 +1,277 @@
|
||||
.project-selector {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.project-selector-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-input-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-selector-trigger:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.project-selector-trigger:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.project-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-dropdown-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border-bottom: 1px solid var(--vscode-dropdown-border);
|
||||
}
|
||||
|
||||
.project-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
.project-item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--vscode-terminal-ansiGreen);
|
||||
}
|
||||
|
||||
.project-empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-dropdown-footer {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--vscode-dropdown-border);
|
||||
}
|
||||
|
||||
.create-project-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-project-btn:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-widget-border);
|
||||
border-radius: 6px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--vscode-widget-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-input-foreground);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.form-field input::placeholder,
|
||||
.form-field textarea::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--vscode-widget-border);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 14px;
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 14px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
202
src/renderer/components/ProjectSelector/ProjectSelector.tsx
Normal file
202
src/renderer/components/ProjectSelector/ProjectSelector.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAppStore, ProjectData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import './ProjectSelector.css';
|
||||
|
||||
export const ProjectSelector: React.FC = () => {
|
||||
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia } = useAppStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectDescription, setNewProjectDescription] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load projects on mount
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const allProjects = await window.electronAPI?.projects.getAll();
|
||||
if (allProjects) {
|
||||
setProjects(allProjects as ProjectData[]);
|
||||
}
|
||||
const active = await window.electronAPI?.projects.getActive();
|
||||
if (active) {
|
||||
setActiveProject(active as ProjectData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
}
|
||||
};
|
||||
loadProjects();
|
||||
}, [setProjects, setActiveProject]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSwitchProject = async (project: ProjectData) => {
|
||||
if (project.id === activeProject?.id) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedProject = await window.electronAPI?.projects.setActive(project.id);
|
||||
if (updatedProject) {
|
||||
setActiveProject(updatedProject as ProjectData);
|
||||
// Clear current selection and reload data
|
||||
setSelectedPost(null);
|
||||
setSelectedMedia(null);
|
||||
|
||||
// Reload posts and media for new project
|
||||
const [posts, media] = await Promise.all([
|
||||
window.electronAPI?.posts.getAll(),
|
||||
window.electronAPI?.media.getAll(),
|
||||
]);
|
||||
if (posts) setPosts(posts);
|
||||
if (media) setMedia(media);
|
||||
|
||||
showToast.success(`Switched to ${project.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to switch project:', error);
|
||||
showToast.error('Failed to switch project');
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newProjectName.trim()) return;
|
||||
|
||||
try {
|
||||
const newProject = await window.electronAPI?.projects.create({
|
||||
name: newProjectName.trim(),
|
||||
description: newProjectDescription.trim() || undefined,
|
||||
});
|
||||
if (newProject) {
|
||||
setProjects([...projects, newProject as ProjectData]);
|
||||
showToast.success(`Created project "${newProjectName}"`);
|
||||
setNewProjectName('');
|
||||
setNewProjectDescription('');
|
||||
setShowCreateModal(false);
|
||||
|
||||
// Optionally switch to the new project
|
||||
await handleSwitchProject(newProject as ProjectData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showToast.error('Failed to create project');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="project-selector" ref={dropdownRef}>
|
||||
<button
|
||||
className="project-selector-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="Switch project"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="project-icon">
|
||||
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
|
||||
</svg>
|
||||
<span className="project-name">{activeProject?.name || 'Select Project'}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="dropdown-arrow">
|
||||
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="project-dropdown">
|
||||
<div className="project-dropdown-header">
|
||||
<span>PROJECTS</span>
|
||||
</div>
|
||||
<div className="project-list">
|
||||
{projects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`project-item ${project.id === activeProject?.id ? 'active' : ''}`}
|
||||
onClick={() => handleSwitchProject(project)}
|
||||
>
|
||||
<span className="project-item-name">{project.name}</span>
|
||||
{project.id === activeProject?.id && (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="check-icon">
|
||||
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<div className="project-empty">No projects yet</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-dropdown-footer">
|
||||
<button className="create-project-btn" onClick={() => { setShowCreateModal(true); setIsOpen(false); }}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||
</svg>
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Create New Project</h3>
|
||||
<button className="modal-close" onClick={() => setShowCreateModal(false)}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateProject}>
|
||||
<div className="modal-body">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-name">Project Name</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
placeholder="My Blog"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description">Description (optional)</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
value={newProjectDescription}
|
||||
onChange={e => setNewProjectDescription(e.target.value)}
|
||||
placeholder="A brief description of this project..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSelector;
|
||||
2
src/renderer/components/ProjectSelector/index.ts
Normal file
2
src/renderer/components/ProjectSelector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProjectSelector } from './ProjectSelector';
|
||||
export { default } from './ProjectSelector';
|
||||
@@ -201,3 +201,261 @@
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 6px 28px 6px 8px;
|
||||
font-size: 12px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
color: var(--vscode-input-foreground);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.search-box button[type="submit"] {
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-box button[type="submit"]:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-box .clear-search {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-box .clear-search:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sidebar header actions */
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-action.active {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Calendar View */
|
||||
.calendar-view {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-header .clear-filter {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calendar-header .clear-filter:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.calendar-years {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-year-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.calendar-year-header:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.calendar-year-header.selected {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
.calendar-year-header .expand-icon {
|
||||
font-size: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.calendar-year-header .year-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-year-header .year-count {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background-color: var(--vscode-badge-background);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.calendar-months {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding-left: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.calendar-month:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.calendar-month.selected {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
.calendar-month .month-count {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.calendar-empty {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Filter Panel */
|
||||
.filter-panel {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
/* Filter Status */
|
||||
.filter-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.filter-status button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-status button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { ProjectSelector } from '../ProjectSelector';
|
||||
import './Sidebar.css';
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -13,8 +15,290 @@ const formatFileSize = (bytes: number) => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
interface CalendarViewProps {
|
||||
onDateSelect: (year: number, month?: number) => void;
|
||||
selectedYear?: number;
|
||||
selectedMonth?: number;
|
||||
}
|
||||
|
||||
const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
|
||||
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||||
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const data = await window.electronAPI?.posts.getByYearMonth();
|
||||
if (data) {
|
||||
setYearMonthData(data as { year: number; month: number; count: number }[]);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Group by year
|
||||
const years = [...new Set(yearMonthData.map(d => d.year))].sort((a, b) => b - a);
|
||||
|
||||
const getYearCount = (year: number) => {
|
||||
return yearMonthData.filter(d => d.year === year).reduce((sum, d) => sum + d.count, 0);
|
||||
};
|
||||
|
||||
const getMonthsForYear = (year: number) => {
|
||||
return yearMonthData.filter(d => d.year === year).sort((a, b) => b.month - a.month);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calendar-view">
|
||||
<div className="calendar-header">
|
||||
<span>ARCHIVE</span>
|
||||
{(selectedYear || selectedMonth !== undefined) && (
|
||||
<button className="clear-filter" onClick={() => onDateSelect(0)} title="Clear filter">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="calendar-years">
|
||||
{years.map(year => (
|
||||
<div key={year} className="calendar-year">
|
||||
<div
|
||||
className={`calendar-year-header ${selectedYear === year && selectedMonth === undefined ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setExpandedYear(expandedYear === year ? null : year);
|
||||
onDateSelect(year);
|
||||
}}
|
||||
>
|
||||
<span className="expand-icon">{expandedYear === year ? '▼' : '▶'}</span>
|
||||
<span className="year-label">{year}</span>
|
||||
<span className="year-count">{getYearCount(year)}</span>
|
||||
</div>
|
||||
{expandedYear === year && (
|
||||
<div className="calendar-months">
|
||||
{getMonthsForYear(year).map(({ month, count }) => (
|
||||
<div
|
||||
key={month}
|
||||
className={`calendar-month ${selectedYear === year && selectedMonth === month ? 'selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDateSelect(year, month);
|
||||
}}
|
||||
>
|
||||
<span className="month-label">{MONTH_NAMES[month]}</span>
|
||||
<span className="month-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{years.length === 0 && (
|
||||
<div className="calendar-empty">No posts yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FilterPanelProps {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
selectedTags: string[];
|
||||
selectedCategories: string[];
|
||||
onTagSelect: (tags: string[]) => void;
|
||||
onCategorySelect: (categories: string[]) => void;
|
||||
}
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
tags,
|
||||
categories,
|
||||
selectedTags,
|
||||
selectedCategories,
|
||||
onTagSelect,
|
||||
onCategorySelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
{tags.length > 0 && (
|
||||
<div className="filter-section">
|
||||
<div className="filter-header">TAGS</div>
|
||||
<div className="filter-chips">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
onTagSelect(selectedTags.filter(t => t !== tag));
|
||||
} else {
|
||||
onTagSelect([...selectedTags, tag]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<div className="filter-section">
|
||||
<div className="filter-header">CATEGORIES</div>
|
||||
<div className="filter-chips">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`filter-chip ${selectedCategories.includes(cat) ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectedCategories.includes(cat)) {
|
||||
onCategorySelect(selectedCategories.filter(c => c !== cat));
|
||||
} else {
|
||||
onCategorySelect([...selectedCategories, cat]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchBoxProps {
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="search-box" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" title="Search">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{query && (
|
||||
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title="Clear">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const PostsList: React.FC = () => {
|
||||
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<PostData[] | null>(null);
|
||||
const [selectedYear, setSelectedYear] = useState<number | undefined>();
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null);
|
||||
|
||||
// Load available tags and categories
|
||||
useEffect(() => {
|
||||
const loadFilters = async () => {
|
||||
const [tags, categories] = await Promise.all([
|
||||
window.electronAPI?.posts.getTags(),
|
||||
window.electronAPI?.posts.getCategories(),
|
||||
]);
|
||||
if (tags) setAvailableTags(tags as string[]);
|
||||
if (categories) setAvailableCategories(categories as string[]);
|
||||
};
|
||||
loadFilters();
|
||||
}, [posts]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = async (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (!query.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await window.electronAPI?.posts.search(query);
|
||||
if (results) {
|
||||
// Map search results to PostData (search returns SearchResult with score)
|
||||
const postIds = (results as { id: string }[]).map(r => r.id);
|
||||
setSearchResults(posts.filter(p => postIds.includes(p.id)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
showToast.error('Search failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle date selection
|
||||
const handleDateSelect = async (year: number, month?: number) => {
|
||||
if (year === 0) {
|
||||
// Clear filter
|
||||
setSelectedYear(undefined);
|
||||
setSelectedMonth(undefined);
|
||||
setFilteredPosts(null);
|
||||
return;
|
||||
}
|
||||
setSelectedYear(year);
|
||||
setSelectedMonth(month);
|
||||
|
||||
try {
|
||||
const results = await window.electronAPI?.posts.filter({
|
||||
year,
|
||||
month,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||
});
|
||||
if (results) {
|
||||
setFilteredPosts(results as PostData[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Filter failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tag/category filter changes
|
||||
useEffect(() => {
|
||||
const applyFilters = async () => {
|
||||
if (!selectedYear && selectedTags.length === 0 && selectedCategories.length === 0) {
|
||||
setFilteredPosts(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await window.electronAPI?.posts.filter({
|
||||
year: selectedYear,
|
||||
month: selectedMonth,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||
});
|
||||
if (results) {
|
||||
setFilteredPosts(results as PostData[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Filter failed:', error);
|
||||
}
|
||||
};
|
||||
applyFilters();
|
||||
}, [selectedTags, selectedCategories]);
|
||||
|
||||
const handleCreatePost = async () => {
|
||||
try {
|
||||
@@ -24,16 +308,33 @@ const PostsList: React.FC = () => {
|
||||
});
|
||||
if (newPost) {
|
||||
setSelectedPost((newPost as PostData).id);
|
||||
showToast.success('Post created');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error);
|
||||
showToast.error('Failed to create post');
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which posts to display
|
||||
const displayPosts = searchResults ?? filteredPosts ?? posts;
|
||||
const isFiltered = searchResults !== null || filteredPosts !== null;
|
||||
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
|
||||
|
||||
const groupedPosts = {
|
||||
draft: posts.filter(p => p.status === 'draft'),
|
||||
published: posts.filter(p => p.status === 'published'),
|
||||
archived: posts.filter(p => p.status === 'archived'),
|
||||
draft: displayPosts.filter(p => p.status === 'draft'),
|
||||
published: displayPosts.filter(p => p.status === 'published'),
|
||||
archived: displayPosts.filter(p => p.status === 'archived'),
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSearchResults(null);
|
||||
setSelectedYear(undefined);
|
||||
setSelectedMonth(undefined);
|
||||
setSelectedTags([]);
|
||||
setSelectedCategories([]);
|
||||
setFilteredPosts(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -41,14 +342,57 @@ const PostsList: React.FC = () => {
|
||||
<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 className="sidebar-actions">
|
||||
<button
|
||||
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
title="Toggle Filters"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<SearchBox onSearch={handleSearch} />
|
||||
|
||||
{showFilters && (
|
||||
<>
|
||||
<CalendarView
|
||||
onDateSelect={handleDateSelect}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
/>
|
||||
<FilterPanel
|
||||
tags={availableTags}
|
||||
categories={availableCategories}
|
||||
selectedTags={selectedTags}
|
||||
selectedCategories={selectedCategories}
|
||||
onTagSelect={setSelectedTags}
|
||||
onCategorySelect={setSelectedCategories}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="filter-status">
|
||||
<span>
|
||||
{displayPosts.length} result{displayPosts.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
</span>
|
||||
<button onClick={clearAllFilters} title="Clear all filters">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedPosts.draft.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
@@ -112,12 +456,19 @@ const PostsList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 && (
|
||||
{displayPosts.length === 0 && !isFiltered && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No posts yet</p>
|
||||
<button onClick={handleCreatePost}>Create your first post</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPosts.length === 0 && isFiltered && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No matching posts</p>
|
||||
<button onClick={clearAllFilters}>Clear filters</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -280,6 +631,7 @@ export const Sidebar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<ProjectSelector />
|
||||
{activeView === 'posts' && <PostsList />}
|
||||
{activeView === 'media' && <MediaList />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
|
||||
14
src/renderer/components/Toast/Toast.css
Normal file
14
src/renderer/components/Toast/Toast.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.toast-container {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* Custom styling for toast animations */
|
||||
:root {
|
||||
--toast-enter-duration: 200ms;
|
||||
--toast-exit-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Override for VS Code dark theme compatibility */
|
||||
[data-sonner-toast] {
|
||||
font-family: var(--vscode-font-family, 'Segoe UI', sans-serif);
|
||||
}
|
||||
88
src/renderer/components/Toast/Toast.tsx
Normal file
88
src/renderer/components/Toast/Toast.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import './Toast.css';
|
||||
|
||||
// Re-export toast for use throughout the app
|
||||
export { toast };
|
||||
|
||||
// Toast types
|
||||
export type ToastType = 'success' | 'error' | 'loading' | 'info';
|
||||
|
||||
// Custom toast functions
|
||||
export const showToast = {
|
||||
success: (message: string) => toast.success(message, {
|
||||
duration: 4000,
|
||||
position: 'bottom-right',
|
||||
}),
|
||||
|
||||
error: (message: string) => toast.error(message, {
|
||||
duration: 6000,
|
||||
position: 'bottom-right',
|
||||
}),
|
||||
|
||||
info: (message: string) => toast(message, {
|
||||
duration: 4000,
|
||||
position: 'bottom-right',
|
||||
icon: 'ℹ️',
|
||||
}),
|
||||
|
||||
loading: (message: string) => toast.loading(message, {
|
||||
position: 'bottom-right',
|
||||
}),
|
||||
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
msgs: { loading: string; success: string; error: string }
|
||||
) => toast.promise(promise, msgs, {
|
||||
position: 'bottom-right',
|
||||
}),
|
||||
|
||||
dismiss: (toastId?: string) => {
|
||||
if (toastId) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
toast.dismiss();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Toast container component
|
||||
export const ToastContainer: React.FC = () => {
|
||||
return (
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
reverseOrder={false}
|
||||
gutter={8}
|
||||
containerClassName="toast-container"
|
||||
toastOptions={{
|
||||
// Default options for all toasts
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'var(--vscode-notifications-background, #252526)',
|
||||
color: 'var(--vscode-notifications-foreground, #cccccc)',
|
||||
border: '1px solid var(--vscode-notifications-border, #3c3c3c)',
|
||||
borderRadius: '4px',
|
||||
padding: '12px 16px',
|
||||
fontSize: '13px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
// Type-specific styling
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--vscode-testing-iconPassed, #89d185)',
|
||||
secondary: 'var(--vscode-notifications-background, #252526)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--vscode-testing-iconFailed, #f14c4c)',
|
||||
secondary: 'var(--vscode-notifications-background, #252526)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
1
src/renderer/components/Toast/index.ts
Normal file
1
src/renderer/components/Toast/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||
@@ -3,3 +3,5 @@ export { Sidebar } from './Sidebar';
|
||||
export { Editor } from './Editor';
|
||||
export { StatusBar } from './StatusBar';
|
||||
export { Panel } from './Panel';
|
||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||
export { ProjectSelector } from './ProjectSelector';
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Types
|
||||
export interface ProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PostData {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -43,6 +53,10 @@ export interface TaskProgress {
|
||||
|
||||
// App State Store
|
||||
interface AppState {
|
||||
// Projects
|
||||
projects: ProjectData[];
|
||||
activeProject: ProjectData | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings';
|
||||
sidebarVisible: boolean;
|
||||
@@ -64,6 +78,13 @@ interface AppState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects: ProjectData[]) => void;
|
||||
setActiveProject: (project: ProjectData | null) => void;
|
||||
addProject: (project: ProjectData) => void;
|
||||
updateProject: (id: string, project: Partial<ProjectData>) => void;
|
||||
removeProject: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
|
||||
toggleSidebar: () => void;
|
||||
@@ -93,6 +114,10 @@ interface AppState {
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
// Initial Project State
|
||||
projects: [],
|
||||
activeProject: null,
|
||||
|
||||
// Initial UI State
|
||||
activeView: 'posts',
|
||||
sidebarVisible: true,
|
||||
@@ -114,6 +139,17 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects) => set({ projects }),
|
||||
setActiveProject: (activeProject) => set({ activeProject }),
|
||||
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
|
||||
updateProject: (id, updatedProject) => set((state) => ({
|
||||
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
|
||||
})),
|
||||
removeProject: (id) => set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
})),
|
||||
|
||||
// UI Actions
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||
export { useAppStore, type ProjectData, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||
|
||||
44
src/renderer/types/electron.d.ts
vendored
44
src/renderer/types/electron.d.ts
vendored
@@ -1,7 +1,18 @@
|
||||
// Type definitions for the Electron API exposed via preload
|
||||
|
||||
export interface ProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PostData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
@@ -15,8 +26,27 @@ export interface PostData {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface PostFilter {
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface MediaData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
@@ -56,6 +86,15 @@ export interface SyncResult {
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<ProjectData | null>;
|
||||
getAll: () => Promise<ProjectData[]>;
|
||||
getActive: () => Promise<ProjectData | null>;
|
||||
setActive: (id: string) => Promise<ProjectData | null>;
|
||||
};
|
||||
posts: {
|
||||
create: (data: Partial<PostData>) => Promise<PostData>;
|
||||
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
||||
@@ -66,6 +105,11 @@ export interface ElectronAPI {
|
||||
publish: (id: string) => Promise<PostData | null>;
|
||||
unpublish: (id: string) => Promise<PostData | null>;
|
||||
rebuildFromFiles: () => Promise<void>;
|
||||
search: (query: string) => Promise<SearchResult[]>;
|
||||
filter: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||
|
||||
Reference in New Issue
Block a user