feat: more feature implementations
This commit is contained in:
@@ -190,12 +190,22 @@ export class DatabaseConnection {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_post_id TEXT NOT NULL,
|
||||
target_post_id TEXT NOT NULL,
|
||||
link_text TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
|
||||
`);
|
||||
|
||||
// Check if project_id column exists in posts table, add if missing (migration)
|
||||
|
||||
@@ -72,6 +72,15 @@ export const settings = sqliteTable('settings', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Post links - tracks internal links between posts
|
||||
export const postLinks = sqliteTable('post_links', {
|
||||
id: text('id').primaryKey(),
|
||||
sourcePostId: text('source_post_id').notNull(), // Post containing the link
|
||||
targetPostId: text('target_post_id').notNull(), // Post being linked to
|
||||
linkText: text('link_text'), // The text of the link
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -83,3 +92,5 @@ export type SyncLogEntry = typeof syncLog.$inferSelect;
|
||||
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
export type PostLink = typeof postLinks.$inferSelect;
|
||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||
|
||||
@@ -7,6 +7,15 @@ import { eq } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia } from '../database/schema';
|
||||
|
||||
// Thumbnail sizes
|
||||
const THUMBNAIL_SIZES = {
|
||||
small: { width: 150, height: 150 },
|
||||
medium: { width: 400, height: 400 },
|
||||
large: { width: 800, height: 800 },
|
||||
} as const;
|
||||
|
||||
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface MediaData {
|
||||
@@ -88,6 +97,105 @@ export class MediaEngine extends EventEmitter {
|
||||
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnails directory for the current project
|
||||
*/
|
||||
private getThumbnailsDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId, 'thumbnails');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnails for an image file
|
||||
*/
|
||||
async generateThumbnails(mediaId: string, sourcePath: string): Promise<Record<ThumbnailSize, string>> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
await fs.mkdir(thumbnailsDir, { recursive: true });
|
||||
|
||||
const thumbnails: Record<ThumbnailSize, string> = {} as Record<ThumbnailSize, string>;
|
||||
|
||||
try {
|
||||
// Dynamic import of sharp (it's a native module)
|
||||
const sharp = (await import('sharp')).default;
|
||||
|
||||
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
|
||||
await sharp(sourcePath)
|
||||
.resize(dimensions.width, dimensions.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
thumbnails[size] = thumbnailPath;
|
||||
}
|
||||
|
||||
this.emit('thumbnailsGenerated', { mediaId, thumbnails });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnails:', error);
|
||||
// Return empty thumbnails on error - non-critical failure
|
||||
}
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing thumbnail paths for a media item
|
||||
*/
|
||||
async getThumbnailPaths(mediaId: string): Promise<Record<ThumbnailSize, string | null>> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
const result: Record<ThumbnailSize, string | null> = {
|
||||
small: null,
|
||||
medium: null,
|
||||
large: null,
|
||||
};
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
result[size] = thumbnailPath;
|
||||
} catch {
|
||||
// Thumbnail doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail as base64 data URL for renderer
|
||||
*/
|
||||
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(thumbnailPath);
|
||||
return `data:image/webp;base64,${data.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete thumbnails for a media item
|
||||
*/
|
||||
private async deleteThumbnails(mediaId: string): Promise<void> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch {
|
||||
// Thumbnail doesn't exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
||||
const sidecarPath = `${mediaPath}.meta`;
|
||||
|
||||
@@ -239,14 +347,30 @@ export class MediaEngine extends EventEmitter {
|
||||
// Copy file to media directory
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
|
||||
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
|
||||
let width = metadata?.width;
|
||||
let height = metadata?.height;
|
||||
|
||||
// Get image dimensions using sharp if it's an image
|
||||
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||
try {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const imageMetadata = await sharp(destPath).metadata();
|
||||
width = imageMetadata.width;
|
||||
height = imageMetadata.height;
|
||||
} catch (error) {
|
||||
console.error('Failed to get image dimensions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaData: MediaData = {
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType: metadata?.mimeType || this.getMimeType(originalName),
|
||||
mimeType,
|
||||
size: sourceBuffer.length,
|
||||
width: metadata?.width,
|
||||
height: metadata?.height,
|
||||
width,
|
||||
height,
|
||||
alt: metadata?.alt,
|
||||
caption: metadata?.caption,
|
||||
createdAt: now,
|
||||
@@ -257,6 +381,13 @@ export class MediaEngine extends EventEmitter {
|
||||
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
||||
const checksum = this.calculateChecksum(sourceBuffer);
|
||||
|
||||
// Generate thumbnails for images (async, non-blocking)
|
||||
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||
this.generateThumbnails(id, destPath).catch(err => {
|
||||
console.error('Failed to generate thumbnails:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const dbMedia: NewMedia = {
|
||||
id: mediaData.id,
|
||||
projectId: this.currentProjectId,
|
||||
@@ -339,6 +470,9 @@ export class MediaEngine extends EventEmitter {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
// Delete thumbnails
|
||||
await this.deleteThumbnails(id);
|
||||
|
||||
await db.delete(media).where(eq(media.id, id));
|
||||
|
||||
this.emit('mediaDeleted', id);
|
||||
|
||||
@@ -7,7 +7,7 @@ import matter from 'gray-matter';
|
||||
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 { posts, Post, NewPost, postLinks } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface PostData {
|
||||
@@ -289,6 +289,11 @@ export class PostEngine extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
// Update post links if content changed
|
||||
if (data.content) {
|
||||
await this.updatePostLinks(id, updated.content);
|
||||
}
|
||||
|
||||
this.emit('postUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
@@ -635,6 +640,140 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
await taskManager.runTask(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract internal post links from content (links to other posts in the blog)
|
||||
*/
|
||||
extractInternalLinks(content: string): { slug: string; text: string }[] {
|
||||
const links: { slug: string; text: string }[] = [];
|
||||
|
||||
// Match markdown links: [text](/posts/slug) or [text](/year/month/slug)
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\(\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?\)/gi;
|
||||
let match;
|
||||
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||
links.push({ text: match[1], slug: match[2] });
|
||||
}
|
||||
|
||||
// Match HTML links: <a href="/posts/slug">text</a>
|
||||
const htmlLinkRegex = /<a[^>]+href=["']\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?["'][^>]*>([^<]+)<\/a>/gi;
|
||||
while ((match = htmlLinkRegex.exec(content)) !== null) {
|
||||
links.push({ text: match[2], slug: match[1] });
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post links in the database based on content analysis
|
||||
*/
|
||||
async updatePostLinks(postId: string, content: string): Promise<void> {
|
||||
const db = getDatabase().getLocal();
|
||||
const extractedLinks = this.extractInternalLinks(content);
|
||||
|
||||
// Delete existing links from this post
|
||||
await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (extractedLinks.length === 0) return;
|
||||
|
||||
// Get all posts to resolve slugs to IDs
|
||||
const allPosts = await db.select({ id: posts.id, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
const slugToId = new Map(allPosts.map(p => [p.slug, p.id]));
|
||||
|
||||
// Insert new links
|
||||
for (const link of extractedLinks) {
|
||||
const targetId = slugToId.get(link.slug);
|
||||
if (targetId && targetId !== postId) {
|
||||
await db.insert(postLinks).values({
|
||||
id: uuidv4(),
|
||||
sourcePostId: postId,
|
||||
targetPostId: targetId,
|
||||
linkText: link.text,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts that link TO the specified post ("linked by")
|
||||
*/
|
||||
async getLinkedBy(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const links = await db
|
||||
.select({
|
||||
sourcePostId: postLinks.sourcePostId,
|
||||
linkText: postLinks.linkText,
|
||||
})
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.targetPostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
|
||||
const sourceIds = links.map(l => l.sourcePostId);
|
||||
const sourcePosts = await db
|
||||
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
return sourcePosts.filter(p => sourceIds.includes(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts that the specified post links TO ("links to")
|
||||
*/
|
||||
async getLinksTo(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const links = await db
|
||||
.select({
|
||||
targetPostId: postLinks.targetPostId,
|
||||
linkText: postLinks.linkText,
|
||||
})
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
|
||||
const targetIds = links.map(l => l.targetPostId);
|
||||
const targetPosts = await db
|
||||
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
return targetPosts.filter(p => targetIds.includes(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild all post links from content analysis
|
||||
*/
|
||||
async rebuildAllPostLinks(): Promise<void> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
// Clear all existing links
|
||||
await db.delete(postLinks);
|
||||
|
||||
// Get all posts
|
||||
const allPosts = await db
|
||||
.select({ id: posts.id, filePath: posts.filePath })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
for (const post of allPosts) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(post.filePath, 'utf-8');
|
||||
const { content } = matter(fileContent);
|
||||
await this.updatePostLinks(post.id, content);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update links for post ${post.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('postLinksRebuilt');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ipcMain, dialog, shell } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
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';
|
||||
import { media } from '../database/schema';
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Project Handlers ============
|
||||
@@ -126,6 +128,21 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getPostsByYearMonth();
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getLinksTo(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getLinkedBy', async (_, id: string) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getLinkedBy(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:rebuildLinks', async () => {
|
||||
const engine = getPostEngine();
|
||||
return engine.rebuildAllPostLinks();
|
||||
});
|
||||
|
||||
// ============ Media Handlers ============
|
||||
|
||||
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
||||
@@ -187,6 +204,24 @@ export function registerIpcHandlers(): void {
|
||||
return engine.rebuildDatabaseFromFiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getThumbnailDataUrl(id, size || 'small');
|
||||
});
|
||||
|
||||
ipcMain.handle('media:regenerateThumbnails', async (_, id: string) => {
|
||||
const engine = getMediaEngine();
|
||||
const mediaItem = await engine.getMedia(id);
|
||||
if (mediaItem && mediaItem.mimeType.startsWith('image/')) {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (dbMedia) {
|
||||
return engine.generateThumbnails(id, dbMedia.filePath);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// ============ Sync Handlers ============
|
||||
|
||||
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
|
||||
|
||||
@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
||||
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
||||
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
||||
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
|
||||
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
|
||||
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
|
||||
},
|
||||
|
||||
// Media
|
||||
@@ -41,6 +44,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
||||
getAll: () => ipcRenderer.invoke('media:getAll'),
|
||||
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||
},
|
||||
|
||||
// Sync
|
||||
@@ -107,6 +112,9 @@ export interface ElectronAPI {
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
rebuildLinks: () => Promise<void>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
||||
|
||||
106
src/renderer/components/CredentialsPanel/CredentialsPanel.css
Normal file
106
src/renderer/components/CredentialsPanel/CredentialsPanel.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.credentials-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.credentials-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.credentials-tabs button {
|
||||
padding: 6px 12px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.credentials-tabs button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.credentials-tabs button.active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.credentials-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.credentials-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.credentials-header h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.credentials-header p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.credentials-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.credentials-field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.credentials-field input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-visibility {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.credentials-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.credentials-actions button {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
283
src/renderer/components/CredentialsPanel/CredentialsPanel.tsx
Normal file
283
src/renderer/components/CredentialsPanel/CredentialsPanel.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { showToast } from '../Toast';
|
||||
import './CredentialsPanel.css';
|
||||
|
||||
interface Credentials {
|
||||
tursoUrl: string;
|
||||
tursoToken: string;
|
||||
ftpHost?: string;
|
||||
ftpUser?: string;
|
||||
ftpPassword?: string;
|
||||
sshHost?: string;
|
||||
sshUser?: string;
|
||||
sshKeyPath?: string;
|
||||
}
|
||||
|
||||
export const CredentialsPanel: React.FC = () => {
|
||||
const [credentials, setCredentials] = useState<Credentials>({
|
||||
tursoUrl: '',
|
||||
tursoToken: '',
|
||||
ftpHost: '',
|
||||
ftpUser: '',
|
||||
ftpPassword: '',
|
||||
sshHost: '',
|
||||
sshUser: '',
|
||||
sshKeyPath: '',
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<'sync' | 'ftp' | 'ssh'>('sync');
|
||||
const [showTokens, setShowTokens] = useState(false);
|
||||
|
||||
// Load saved credentials (in a real app, use secure storage)
|
||||
useEffect(() => {
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const savedCreds = localStorage.getItem('bds-credentials');
|
||||
if (savedCreds) {
|
||||
setCredentials(JSON.parse(savedCreds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials:', error);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Save to localStorage (in production, use secure storage)
|
||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||
|
||||
// Configure sync if Turso credentials are set
|
||||
if (credentials.tursoUrl && credentials.tursoToken) {
|
||||
await window.electronAPI?.sync.configure({
|
||||
tursoUrl: credentials.tursoUrl,
|
||||
tursoAuthToken: credentials.tursoToken,
|
||||
autoSync: true,
|
||||
syncInterval: 5,
|
||||
});
|
||||
}
|
||||
|
||||
showToast.success('Credentials saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save credentials:', error);
|
||||
showToast.error('Failed to save credentials');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (type: 'sync' | 'ftp' | 'ssh') => {
|
||||
const newCreds = { ...credentials };
|
||||
switch (type) {
|
||||
case 'sync':
|
||||
newCreds.tursoUrl = '';
|
||||
newCreds.tursoToken = '';
|
||||
break;
|
||||
case 'ftp':
|
||||
newCreds.ftpHost = '';
|
||||
newCreds.ftpUser = '';
|
||||
newCreds.ftpPassword = '';
|
||||
break;
|
||||
case 'ssh':
|
||||
newCreds.sshHost = '';
|
||||
newCreds.sshUser = '';
|
||||
newCreds.sshKeyPath = '';
|
||||
break;
|
||||
}
|
||||
setCredentials(newCreds);
|
||||
};
|
||||
|
||||
const handleTestConnection = async (type: 'sync' | 'ftp' | 'ssh') => {
|
||||
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
|
||||
|
||||
// Simulate connection test
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// In a real implementation, this would test the actual connection
|
||||
if (type === 'sync' && credentials.tursoUrl && credentials.tursoToken) {
|
||||
showToast.dismiss();
|
||||
showToast.success('Sync connection successful');
|
||||
} else {
|
||||
showToast.dismiss();
|
||||
showToast.error('Connection failed - check credentials');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="credentials-panel">
|
||||
<div className="credentials-tabs">
|
||||
<button
|
||||
className={activeTab === 'sync' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('sync')}
|
||||
>
|
||||
Cloud Sync
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'ftp' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('ftp')}
|
||||
>
|
||||
FTP
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'ssh' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('ssh')}
|
||||
>
|
||||
SSH
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="credentials-content">
|
||||
{activeTab === 'sync' && (
|
||||
<div className="credentials-form">
|
||||
<div className="credentials-header">
|
||||
<h4>Turso/LibSQL Cloud Sync</h4>
|
||||
<p className="text-muted">
|
||||
Connect to Turso for cloud database synchronization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Database URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="libsql://your-database.turso.io"
|
||||
value={credentials.tursoUrl}
|
||||
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>
|
||||
Auth Token
|
||||
<button
|
||||
className="toggle-visibility"
|
||||
onClick={() => setShowTokens(!showTokens)}
|
||||
>
|
||||
{showTokens ? '👁' : '👁🗨'}
|
||||
</button>
|
||||
</label>
|
||||
<input
|
||||
type={showTokens ? 'text' : 'password'}
|
||||
placeholder="Your authentication token"
|
||||
value={credentials.tursoToken}
|
||||
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-actions">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button className="secondary" onClick={() => handleTestConnection('sync')}>
|
||||
Test Connection
|
||||
</button>
|
||||
<button className="secondary danger" onClick={() => handleClear('sync')}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ftp' && (
|
||||
<div className="credentials-form">
|
||||
<div className="credentials-header">
|
||||
<h4>FTP Publishing</h4>
|
||||
<p className="text-muted">
|
||||
Configure FTP for publishing your blog to a web server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Host</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ftp.example.com"
|
||||
value={credentials.ftpHost}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ftp-user"
|
||||
value={credentials.ftpUser}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type={showTokens ? 'text' : 'password'}
|
||||
placeholder="Password"
|
||||
value={credentials.ftpPassword}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-actions">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
|
||||
Test Connection
|
||||
</button>
|
||||
<button className="secondary danger" onClick={() => handleClear('ftp')}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ssh' && (
|
||||
<div className="credentials-form">
|
||||
<div className="credentials-header">
|
||||
<h4>SSH Publishing</h4>
|
||||
<p className="text-muted">
|
||||
Configure SSH for secure publishing to your server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Host</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="server.example.com"
|
||||
value={credentials.sshHost}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ssh-user"
|
||||
value={credentials.sshUser}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>SSH Key Path</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
value={credentials.sshKeyPath}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-actions">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
|
||||
Test Connection
|
||||
</button>
|
||||
<button className="secondary danger" onClick={() => handleClear('ssh')}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsPanel;
|
||||
1
src/renderer/components/CredentialsPanel/index.ts
Normal file
1
src/renderer/components/CredentialsPanel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CredentialsPanel } from './CredentialsPanel';
|
||||
@@ -186,6 +186,22 @@
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.gallery-button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gallery-button:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
flex: 1;
|
||||
background-color: var(--vscode-input-background);
|
||||
@@ -196,6 +212,63 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.editor-preview .preview-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.editor-preview h1,
|
||||
.editor-preview h2,
|
||||
.editor-preview h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.editor-preview h1 { font-size: 2em; }
|
||||
.editor-preview h2 { font-size: 1.5em; }
|
||||
.editor-preview h3 { font-size: 1.25em; }
|
||||
|
||||
.editor-preview code {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
.editor-preview pre {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor-preview pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-preview blockquote {
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
padding-left: 16px;
|
||||
margin-left: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.editor-preview a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
|
||||
.editor-preview a:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
}
|
||||
|
||||
.editor-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -2,8 +2,43 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { WysiwygEditor } from '../WysiwygEditor';
|
||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
import { PostLinks } from '../PostLinks';
|
||||
import './Editor.css';
|
||||
|
||||
// Simple markdown to HTML converter for preview
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
return markdown
|
||||
// Escape HTML
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Headers
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
// Bold
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
// Images
|
||||
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img alt="$1" src="$2" style="max-width: 100%;" />')
|
||||
// Links
|
||||
.replace(/\[(.*?)\]\((.*?)\)/gim, '<a href="$2" target="_blank">$1</a>')
|
||||
// Code blocks
|
||||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`(.*?)`/gim, '<code>$1</code>')
|
||||
// Blockquotes
|
||||
.replace(/^\> (.*$)/gim, '<blockquote>$1</blockquote>')
|
||||
// Horizontal rules
|
||||
.replace(/^---$/gim, '<hr />')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br />');
|
||||
};
|
||||
|
||||
type EditorMode = 'markdown' | 'wysiwyg' | 'preview';
|
||||
|
||||
interface PostEditorProps {
|
||||
post: PostData;
|
||||
}
|
||||
@@ -15,9 +50,14 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
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 [editorMode, setEditorMode] = useState<EditorMode>('wysiwyg');
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
// Extract images from content for lightbox
|
||||
const images = useMarkdownImages(content);
|
||||
|
||||
// Reset when post changes
|
||||
useEffect(() => {
|
||||
setTitle(post.title);
|
||||
@@ -200,27 +240,59 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostLinks
|
||||
postId={post.id}
|
||||
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
<div className="editor-toolbar">
|
||||
<label>Content (Markdown)</label>
|
||||
<label>Content</label>
|
||||
<div className="editor-mode-toggle">
|
||||
<button
|
||||
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||
onClick={() => setEditorMode('wysiwyg')}
|
||||
title="Visual editor"
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'markdown' ? 'active' : ''}
|
||||
onClick={() => setEditorMode('markdown')}
|
||||
title="Markdown source"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'preview' ? 'active' : ''}
|
||||
onClick={() => setEditorMode('preview')}
|
||||
title="Read-only preview"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<button
|
||||
className="gallery-button"
|
||||
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
||||
title={`View ${images.length} image(s)`}
|
||||
>
|
||||
📷 {images.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editorMode === 'markdown' ? (
|
||||
|
||||
{editorMode === 'wysiwyg' && (
|
||||
<WysiwygEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{editorMode === 'markdown' && (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
@@ -244,13 +316,25 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
cursorBlinking: 'smooth',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{editorMode === 'preview' && (
|
||||
<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
|
||||
className="preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox for viewing images in content */}
|
||||
<Lightbox
|
||||
images={images}
|
||||
initialIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
|
||||
253
src/renderer/components/Lightbox/Lightbox.css
Normal file
253
src/renderer/components/Lightbox/Lightbox.css
Normal file
@@ -0,0 +1,253 @@
|
||||
/* Lightbox Overlay */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Image Container */
|
||||
.lightbox-image-container {
|
||||
max-width: 90%;
|
||||
max-height: 80%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lightbox-image-container.zoomed {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
cursor: zoom-in;
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.lightbox-image-container.zoomed .lightbox-image {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Navigation Arrows */
|
||||
.lightbox-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.lightbox-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.lightbox-prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.lightbox-next {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.lightbox-footer {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.lightbox-counter {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Thumbnails */
|
||||
.lightbox-thumbnails {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lightbox-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, opacity 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.lightbox-thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.lightbox-thumbnail.active {
|
||||
border-color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Image Gallery Grid */
|
||||
.image-gallery {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-gallery.gallery-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.image-gallery.gallery-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.image-gallery.gallery-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Single Image */
|
||||
.single-image {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.single-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.single-image:hover img {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
235
src/renderer/components/Lightbox/Lightbox.tsx
Normal file
235
src/renderer/components/Lightbox/Lightbox.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import './Lightbox.css';
|
||||
|
||||
interface LightboxImage {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
images: LightboxImage[];
|
||||
initialIndex?: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Lightbox: React.FC<LightboxProps> = ({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(initialIndex);
|
||||
}, [initialIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
}
|
||||
}, [isOpen, images.length, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!isOpen || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentImage = images[currentIndex];
|
||||
const hasMultiple = images.length > 1;
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZoom = () => {
|
||||
setIsZoomed(!isZoomed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleBackdropClick}>
|
||||
<div className="lightbox-container">
|
||||
{/* Close button */}
|
||||
<button className="lightbox-close" onClick={onClose} title="Close (Esc)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title="Previous (←)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="lightbox-nav lightbox-next" onClick={handleNext} title="Next (→)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main image */}
|
||||
<div className={`lightbox-image-container ${isZoomed ? 'zoomed' : ''}`}>
|
||||
<img
|
||||
src={currentImage.src}
|
||||
alt={currentImage.alt || ''}
|
||||
onClick={toggleZoom}
|
||||
className="lightbox-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caption and counter */}
|
||||
<div className="lightbox-footer">
|
||||
{currentImage.caption && (
|
||||
<p className="lightbox-caption">{currentImage.caption}</p>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<div className="lightbox-counter">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip for galleries */}
|
||||
{hasMultiple && images.length <= 10 && (
|
||||
<div className="lightbox-thumbnails">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`lightbox-thumbnail ${index === currentIndex ? 'active' : ''}`}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
>
|
||||
<img src={image.src} alt={image.alt || ''} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to extract images from markdown content
|
||||
export function useMarkdownImages(content: string): LightboxImage[] {
|
||||
const [images, setImages] = useState<LightboxImage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Match markdown image syntax: 
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const matches: LightboxImage[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = imageRegex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
alt: match[1] || undefined,
|
||||
src: match[2],
|
||||
});
|
||||
}
|
||||
|
||||
setImages(matches);
|
||||
}, [content]);
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// Component to render images with lightbox support
|
||||
interface ImageGalleryProps {
|
||||
images: LightboxImage[];
|
||||
}
|
||||
|
||||
export const ImageGallery: React.FC<ImageGalleryProps> = ({ images }) => {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setSelectedIndex(index);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<div className="single-image" onClick={() => openLightbox(0)}>
|
||||
<img src={images[0].src} alt={images[0].alt || ''} />
|
||||
{images[0].caption && <p className="image-caption">{images[0].caption}</p>}
|
||||
</div>
|
||||
<Lightbox
|
||||
images={images}
|
||||
initialIndex={selectedIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`image-gallery gallery-${Math.min(images.length, 4)}`}>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="gallery-item"
|
||||
onClick={() => openLightbox(index)}
|
||||
>
|
||||
<img src={image.src} alt={image.alt || ''} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Lightbox
|
||||
images={images}
|
||||
initialIndex={selectedIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lightbox;
|
||||
1
src/renderer/components/Lightbox/index.ts
Normal file
1
src/renderer/components/Lightbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
||||
107
src/renderer/components/PostLinks/PostLinks.css
Normal file
107
src/renderer/components/PostLinks/PostLinks.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.post-links {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-links-loading {
|
||||
padding: 8px 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.post-links-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.post-links-toggle:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.post-links-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.post-links-count {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.post-links-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.post-links-content {
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.post-links-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.post-links-section:not(:last-child) {
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-links-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 4px 0;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.post-links-arrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.post-links-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-link-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 4px 12px 4px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-link-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
117
src/renderer/components/PostLinks/PostLinks.tsx
Normal file
117
src/renderer/components/PostLinks/PostLinks.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './PostLinks.css';
|
||||
|
||||
interface PostLinkInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PostLinksProps {
|
||||
postId: string;
|
||||
onPostClick?: (postId: string) => void;
|
||||
}
|
||||
|
||||
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) => {
|
||||
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
|
||||
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLinks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [to, by] = await Promise.all([
|
||||
window.electronAPI?.posts.getLinksTo(postId),
|
||||
window.electronAPI?.posts.getLinkedBy(postId),
|
||||
]);
|
||||
setLinksTo(to || []);
|
||||
setLinkedBy(by || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load post links:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLinks();
|
||||
}, [postId]);
|
||||
|
||||
const totalLinks = linksTo.length + linkedBy.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="post-links">
|
||||
<div className="post-links-loading">Loading links...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalLinks === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-links">
|
||||
<button
|
||||
className={`post-links-toggle ${expanded ? 'expanded' : ''}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="post-links-icon">🔗</span>
|
||||
<span className="post-links-count">{totalLinks} link{totalLinks !== 1 ? 's' : ''}</span>
|
||||
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="post-links-content">
|
||||
{linksTo.length > 0 && (
|
||||
<div className="post-links-section">
|
||||
<h4 className="post-links-heading">
|
||||
<span className="post-links-arrow">→</span>
|
||||
Links to ({linksTo.length})
|
||||
</h4>
|
||||
<ul className="post-links-list">
|
||||
{linksTo.map(link => (
|
||||
<li key={link.id}>
|
||||
<button
|
||||
className="post-link-item"
|
||||
onClick={() => onPostClick?.(link.id)}
|
||||
title={`Open: ${link.title}`}
|
||||
>
|
||||
{link.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkedBy.length > 0 && (
|
||||
<div className="post-links-section">
|
||||
<h4 className="post-links-heading">
|
||||
<span className="post-links-arrow">←</span>
|
||||
Linked by ({linkedBy.length})
|
||||
</h4>
|
||||
<ul className="post-links-list">
|
||||
{linkedBy.map(link => (
|
||||
<li key={link.id}>
|
||||
<button
|
||||
className="post-link-item"
|
||||
onClick={() => onPostClick?.(link.id)}
|
||||
title={`Open: ${link.title}`}
|
||||
>
|
||||
{link.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostLinks;
|
||||
2
src/renderer/components/PostLinks/index.ts
Normal file
2
src/renderer/components/PostLinks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PostLinks } from './PostLinks';
|
||||
export { default } from './PostLinks';
|
||||
73
src/renderer/components/ResizablePanel/ResizablePanel.css
Normal file
73
src/renderer/components/ResizablePanel/ResizablePanel.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.resizable-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resizable-panel.horizontal {
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resizable-panel.vertical {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resizable-panel.resizing {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizable-panel.resizing .resizable-panel-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizable-panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Resizer handle */
|
||||
.resizer {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
transition: background-color 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resizer.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.resizer.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.resizer:hover,
|
||||
.resizable-panel.resizing .resizer {
|
||||
background-color: var(--vscode-sash-hoverBorder, #0078d4);
|
||||
}
|
||||
|
||||
/* Double-click to reset */
|
||||
.resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.resizer.horizontal::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
.resizer.vertical::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
}
|
||||
123
src/renderer/components/ResizablePanel/ResizablePanel.tsx
Normal file
123
src/renderer/components/ResizablePanel/ResizablePanel.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import './ResizablePanel.css';
|
||||
|
||||
interface ResizablePanelProps {
|
||||
children: React.ReactNode;
|
||||
direction: 'horizontal' | 'vertical';
|
||||
initialSize: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
storageKey?: string;
|
||||
className?: string;
|
||||
resizerPosition?: 'start' | 'end';
|
||||
}
|
||||
|
||||
export const ResizablePanel: React.FC<ResizablePanelProps> = ({
|
||||
children,
|
||||
direction,
|
||||
initialSize,
|
||||
minSize = 150,
|
||||
maxSize = 600,
|
||||
storageKey,
|
||||
className = '',
|
||||
resizerPosition = 'end',
|
||||
}) => {
|
||||
// Load saved size from localStorage
|
||||
const getSavedSize = () => {
|
||||
if (storageKey) {
|
||||
const saved = localStorage.getItem(`bds-panel-${storageKey}`);
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (!isNaN(parsed) && parsed >= minSize && parsed <= maxSize) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return initialSize;
|
||||
};
|
||||
|
||||
const [size, setSize] = useState(getSavedSize);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const startPosRef = useRef(0);
|
||||
const startSizeRef = useRef(0);
|
||||
|
||||
// Save size to localStorage
|
||||
useEffect(() => {
|
||||
if (storageKey && !isResizing) {
|
||||
localStorage.setItem(`bds-panel-${storageKey}`, size.toString());
|
||||
}
|
||||
}, [size, storageKey, isResizing]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
startPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||
startSizeRef.current = size;
|
||||
}, [direction, size]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||
let delta = currentPos - startPosRef.current;
|
||||
|
||||
// Reverse delta if resizer is at start
|
||||
if (resizerPosition === 'start') {
|
||||
delta = -delta;
|
||||
}
|
||||
|
||||
const newSize = Math.max(minSize, Math.min(maxSize, startSizeRef.current + delta));
|
||||
setSize(newSize);
|
||||
}, [isResizing, direction, minSize, maxSize, resizerPosition]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing, handleMouseMove, handleMouseUp, direction]);
|
||||
|
||||
const style: React.CSSProperties = direction === 'horizontal'
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`resizable-panel ${direction} ${className} ${isResizing ? 'resizing' : ''}`}
|
||||
style={style}
|
||||
>
|
||||
{resizerPosition === 'start' && (
|
||||
<div
|
||||
className={`resizer ${direction}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
<div className="resizable-panel-content">
|
||||
{children}
|
||||
</div>
|
||||
{resizerPosition === 'end' && (
|
||||
<div
|
||||
className={`resizer ${direction}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResizablePanel;
|
||||
1
src/renderer/components/ResizablePanel/index.ts
Normal file
1
src/renderer/components/ResizablePanel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ResizablePanel } from './ResizablePanel';
|
||||
@@ -67,7 +67,10 @@
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 6px 12px 6px 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 12px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
@@ -81,6 +84,39 @@
|
||||
border-left-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.post-type-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sidebar-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Post type specific styling */
|
||||
.sidebar-item.post-type-picture {
|
||||
background: linear-gradient(90deg, rgba(139, 92, 246, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sidebar-item.post-type-aside {
|
||||
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sidebar-item.post-type-quote {
|
||||
background: linear-gradient(90deg, rgba(34, 197, 94, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sidebar-item.post-type-link {
|
||||
background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sidebar-item.post-type-video {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sidebar-item-title {
|
||||
font-size: 13px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
|
||||
@@ -14,6 +14,28 @@ const formatFileSize = (bytes: number) => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// Get post type icon based on categories
|
||||
const getPostTypeIcon = (categories: string[]): { icon: string; type: string } => {
|
||||
const lowerCategories = categories.map(c => c.toLowerCase());
|
||||
if (lowerCategories.includes('picture') || lowerCategories.includes('photo') || lowerCategories.includes('image')) {
|
||||
return { icon: '🖼️', type: 'picture' };
|
||||
}
|
||||
if (lowerCategories.includes('aside') || lowerCategories.includes('note') || lowerCategories.includes('quick')) {
|
||||
return { icon: '📝', type: 'aside' };
|
||||
}
|
||||
if (lowerCategories.includes('link') || lowerCategories.includes('bookmark')) {
|
||||
return { icon: '🔗', type: 'link' };
|
||||
}
|
||||
if (lowerCategories.includes('video')) {
|
||||
return { icon: '🎬', type: 'video' };
|
||||
}
|
||||
if (lowerCategories.includes('quote')) {
|
||||
return { icon: '💬', type: 'quote' };
|
||||
}
|
||||
// Default to article
|
||||
return { icon: '📄', type: 'article' };
|
||||
};
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
interface CalendarViewProps {
|
||||
@@ -399,16 +421,22 @@ const PostsList: React.FC = () => {
|
||||
Drafts ({groupedPosts.draft.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.draft.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
{groupedPosts.draft.map(post => {
|
||||
const postType = getPostTypeIcon(post.categories);
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -420,16 +448,22 @@ const PostsList: React.FC = () => {
|
||||
Published ({groupedPosts.published.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.published.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
{groupedPosts.published.map(post => {
|
||||
const postType = getPostTypeIcon(post.categories);
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -441,16 +475,22 @@ const PostsList: React.FC = () => {
|
||||
Archived ({groupedPosts.archived.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.archived.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
{groupedPosts.archived.map(post => {
|
||||
const postType = getPostTypeIcon(post.categories);
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPost(post.id)}
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
230
src/renderer/components/TaskPopup/TaskPopup.css
Normal file
230
src/renderer/components/TaskPopup/TaskPopup.css
Normal file
@@ -0,0 +1,230 @@
|
||||
.task-popup-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-popup-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-statusBar-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.task-popup-trigger:hover {
|
||||
background-color: var(--vscode-statusBarItem-hoverBackground);
|
||||
}
|
||||
|
||||
.task-popup-trigger.active {
|
||||
background-color: var(--vscode-statusBarItem-activeBackground);
|
||||
}
|
||||
|
||||
.task-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 8px;
|
||||
width: 360px;
|
||||
max-height: 400px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.15s ease-out;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.task-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.task-popup-header h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.task-popup-header .text-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-popup-header .text-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.task-section {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.task-section:not(:last-child) {
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.task-section-title {
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.task-item-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item-message {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-item-error {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-notificationsErrorIcon-foreground);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-progress-bar {
|
||||
height: 4px;
|
||||
background-color: var(--vscode-progressBar-background);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--vscode-button-background);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
.task-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.task-item:hover .task-cancel {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task-cancel:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
color: var(--vscode-notificationsErrorIcon-foreground);
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-icon.success {
|
||||
color: var(--vscode-testing-iconPassed);
|
||||
}
|
||||
|
||||
.task-icon.error {
|
||||
color: var(--vscode-notificationsErrorIcon-foreground);
|
||||
}
|
||||
|
||||
.task-icon.pending {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.task-icon.cancelled {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.task-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--vscode-panel-border);
|
||||
border-top-color: var(--vscode-button-background);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.task-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
190
src/renderer/components/TaskPopup/TaskPopup.tsx
Normal file
190
src/renderer/components/TaskPopup/TaskPopup.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import './TaskPopup.css';
|
||||
|
||||
export const TaskPopup: React.FC = () => {
|
||||
const { tasks } = useAppStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const runningTasks = tasks.filter(t => t.status === 'running');
|
||||
const pendingTasks = tasks.filter(t => t.status === 'pending');
|
||||
const recentTasks = tasks
|
||||
.filter(t => t.status === 'completed' || t.status === 'failed')
|
||||
.sort((a, b) => {
|
||||
const aTime = a.endTime ? new Date(a.endTime).getTime() : 0;
|
||||
const bTime = b.endTime ? new Date(b.endTime).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
|
||||
|
||||
// Close popup when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleCancel = async (taskId: string) => {
|
||||
await window.electronAPI?.tasks.cancel(taskId);
|
||||
};
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
await window.electronAPI?.tasks.clearCompleted();
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <span className="task-spinner" />;
|
||||
case 'completed':
|
||||
return <span className="task-icon success">✓</span>;
|
||||
case 'failed':
|
||||
return <span className="task-icon error">✕</span>;
|
||||
case 'pending':
|
||||
return <span className="task-icon pending">○</span>;
|
||||
case 'cancelled':
|
||||
return <span className="task-icon cancelled">⊘</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasActiveTasks && recentTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-popup-wrapper" ref={popupRef}>
|
||||
<button
|
||||
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
|
||||
>
|
||||
{runningTasks.length > 0 ? (
|
||||
<>
|
||||
<span className="task-spinner" />
|
||||
<span>{runningTasks.length} running</span>
|
||||
</>
|
||||
) : pendingTasks.length > 0 ? (
|
||||
<>
|
||||
<span className="task-icon pending">○</span>
|
||||
<span>{pendingTasks.length} pending</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Tasks</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="task-popup">
|
||||
<div className="task-popup-header">
|
||||
<h4>Background Tasks</h4>
|
||||
{recentTasks.length > 0 && (
|
||||
<button className="text-button" onClick={handleClearCompleted}>
|
||||
Clear completed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runningTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Running</div>
|
||||
{runningTasks.map(task => (
|
||||
<div key={task.taskId} className="task-item running">
|
||||
<div className="task-item-info">
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="task-item-details">
|
||||
<div className="task-item-message">{task.message}</div>
|
||||
<div className="task-progress-bar">
|
||||
<div
|
||||
className="task-progress-fill"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="task-cancel"
|
||||
onClick={() => handleCancel(task.taskId)}
|
||||
title="Cancel task"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Pending</div>
|
||||
{pendingTasks.map(task => (
|
||||
<div key={task.taskId} className="task-item pending">
|
||||
<div className="task-item-info">
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="task-item-details">
|
||||
<div className="task-item-message">{task.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="task-cancel"
|
||||
onClick={() => handleCancel(task.taskId)}
|
||||
title="Cancel task"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Recent</div>
|
||||
{recentTasks.map(task => (
|
||||
<div key={task.taskId} className={`task-item ${task.status}`}>
|
||||
<div className="task-item-info">
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="task-item-details">
|
||||
<div className="task-item-message">{task.message}</div>
|
||||
{task.error && (
|
||||
<div className="task-item-error">{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{task.endTime && (
|
||||
<span className="task-time">{formatTime(task.endTime)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
|
||||
<div className="task-empty">No active tasks</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPopup;
|
||||
1
src/renderer/components/TaskPopup/index.ts
Normal file
1
src/renderer/components/TaskPopup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TaskPopup } from './TaskPopup';
|
||||
296
src/renderer/components/WysiwygEditor/WysiwygEditor.css
Normal file
296
src/renderer/components/WysiwygEditor/WysiwygEditor.css
Normal file
@@ -0,0 +1,296 @@
|
||||
.wysiwyg-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background-color: var(--vscode-input-background);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.wysiwyg-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--vscode-panel-border);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.wysiwyg-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 4px 8px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.wysiwyg-toolbar button:hover:not(:disabled) {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.wysiwyg-toolbar button.is-active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.wysiwyg-toolbar button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Editor Content */
|
||||
.wysiwyg-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wysiwyg-content .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 100%;
|
||||
color: var(--vscode-editor-foreground);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.wysiwyg-content .ProseMirror > * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.wysiwyg-content .ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.wysiwyg-content h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.wysiwyg-content h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.wysiwyg-content h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.wysiwyg-content p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.wysiwyg-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wysiwyg-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wysiwyg-content u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wysiwyg-content s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.wysiwyg-content a,
|
||||
.wysiwyg-content .editor-link {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wysiwyg-content a:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.wysiwyg-content ul,
|
||||
.wysiwyg-content ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.wysiwyg-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.wysiwyg-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.wysiwyg-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.wysiwyg-content blockquote {
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
color: var(--vscode-textBlockQuote-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Code */
|
||||
.wysiwyg-content code {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.wysiwyg-content pre {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.wysiwyg-content pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.wysiwyg-content img,
|
||||
.wysiwyg-content .editor-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
margin: 1em 0;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wysiwyg-content img:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Horizontal Rule */
|
||||
.wysiwyg-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* Bubble Menu */
|
||||
.bubble-menu {
|
||||
display: flex;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bubble-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 4px 8px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bubble-menu button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.bubble-menu button.is-active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.bubble-menu .divider {
|
||||
width: 1px;
|
||||
background-color: var(--vscode-panel-border);
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
/* Floating Menu */
|
||||
.floating-menu {
|
||||
display: flex;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.floating-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.floating-menu button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.floating-menu button.is-active {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
379
src/renderer/components/WysiwygEditor/WysiwygEditor.tsx
Normal file
379
src/renderer/components/WysiwygEditor/WysiwygEditor.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TurndownService from 'turndown';
|
||||
import './WysiwygEditor.css';
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
|
||||
// Add custom rules for turndown
|
||||
turndownService.addRule('strikethrough', {
|
||||
filter: ['del', 's', 'strike'],
|
||||
replacement: (content) => `~~${content}~~`,
|
||||
});
|
||||
|
||||
interface WysiwygEditorProps {
|
||||
content: string;
|
||||
onChange: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Simple markdown to HTML converter for TipTap
|
||||
function markdownToHtml(markdown: string): string {
|
||||
// Simple markdown parser - for production use a proper library
|
||||
let html = markdown
|
||||
// Headers
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
// Bold
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Strikethrough
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
// Code blocks
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="$1">$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
// Images
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
|
||||
// Blockquotes
|
||||
.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
|
||||
// Unordered lists
|
||||
.replace(/^\s*[-*+] (.+)$/gim, '<li>$1</li>')
|
||||
// Ordered lists
|
||||
.replace(/^\d+\. (.+)$/gim, '<li>$1</li>')
|
||||
// Horizontal rule
|
||||
.replace(/^(?:---+|___+|\*\*\*+)$/gim, '<hr>')
|
||||
// Paragraphs (double newlines)
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
// Single line breaks
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Wrap in paragraphs if not starting with a block element
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
}
|
||||
|
||||
// Fix consecutive blockquotes
|
||||
html = html.replace(/<\/blockquote>\s*<blockquote>/g, '<br>');
|
||||
|
||||
// Wrap list items in ul/ol
|
||||
html = html.replace(/(<li>.*<\/li>)+/g, (match) => {
|
||||
// Check if it's ordered (starts with number) or unordered
|
||||
return '<ul>' + match + '</ul>';
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Start writing your content...',
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'editor-link',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'editor-image',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: markdownToHtml(content),
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
const markdown = turndownService.turndown(html);
|
||||
onChange(markdown);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
const currentHtml = editor.getHTML();
|
||||
const newHtml = markdownToHtml(content);
|
||||
// Only update if content is significantly different
|
||||
if (turndownService.turndown(currentHtml) !== content) {
|
||||
editor.commands.setContent(newHtml);
|
||||
}
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const url = window.prompt('Enter image URL:');
|
||||
if (url && editor) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('Enter URL:', previousUrl);
|
||||
|
||||
if (url === null) return;
|
||||
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wysiwyg-editor">
|
||||
{/* Bubble menu appears when text is selected */}
|
||||
{editor && (
|
||||
<BubbleMenu className="bubble-menu" editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'is-active' : ''}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={editor.isActive('underline') ? 'is-active' : ''}
|
||||
title="Underline"
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={editor.isActive('strike') ? 'is-active' : ''}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<div className="divider" />
|
||||
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Link">
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
className={editor.isActive('code') ? 'is-active' : ''}
|
||||
title="Code"
|
||||
>
|
||||
{'</>'}
|
||||
</button>
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{/* Floating menu appears on empty lines */}
|
||||
{editor && (
|
||||
<FloatingMenu className="floating-menu" editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'is-active' : ''}
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'is-active' : ''}
|
||||
>
|
||||
❝ Quote
|
||||
</button>
|
||||
<button onClick={addImage}>
|
||||
🖼 Image
|
||||
</button>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="wysiwyg-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
||||
title="Heading 1"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'is-active' : ''}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={editor.isActive('underline') ? 'is-active' : ''}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={editor.isActive('strike') ? 'is-active' : ''}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'is-active' : ''}
|
||||
title="Bullet List"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'is-active' : ''}
|
||||
title="Numbered List"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'is-active' : ''}
|
||||
title="Quote"
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
className={editor.isActive('codeBlock') ? 'is-active' : ''}
|
||||
title="Code Block"
|
||||
>
|
||||
{'{}'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Insert Link">
|
||||
🔗
|
||||
</button>
|
||||
<button onClick={addImage} title="Insert Image">
|
||||
🖼
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
―
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<EditorContent className="wysiwyg-content" editor={editor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WysiwygEditor;
|
||||
1
src/renderer/components/WysiwygEditor/index.ts
Normal file
1
src/renderer/components/WysiwygEditor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WysiwygEditor } from './WysiwygEditor';
|
||||
@@ -5,3 +5,9 @@ export { StatusBar } from './StatusBar';
|
||||
export { Panel } from './Panel';
|
||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||
export { ProjectSelector } from './ProjectSelector';
|
||||
export { WysiwygEditor } from './WysiwygEditor';
|
||||
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
||||
export { TaskPopup } from './TaskPopup';
|
||||
export { ResizablePanel } from './ResizablePanel';
|
||||
export { CredentialsPanel } from './CredentialsPanel';
|
||||
export { PostLinks } from './PostLinks';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Storage key for persisted state
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Types
|
||||
export interface ProjectData {
|
||||
@@ -113,84 +117,99 @@ interface AppState {
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
// Initial Project State
|
||||
projects: [],
|
||||
activeProject: null,
|
||||
|
||||
// Initial UI State
|
||||
activeView: 'posts',
|
||||
sidebarVisible: true,
|
||||
panelVisible: false,
|
||||
selectedPostId: null,
|
||||
selectedMediaId: null,
|
||||
|
||||
// Initial Data
|
||||
posts: [],
|
||||
media: [],
|
||||
tasks: [],
|
||||
|
||||
// Initial Sync State
|
||||
syncStatus: 'idle',
|
||||
syncConfigured: false,
|
||||
pendingChanges: { posts: 0, media: 0 },
|
||||
|
||||
// Initial Loading State
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// 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 })),
|
||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
||||
|
||||
// Post Actions
|
||||
setPosts: (posts) => set({ posts }),
|
||||
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
|
||||
updatePost: (id, updatedPost) => set((state) => ({
|
||||
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
|
||||
})),
|
||||
removePost: (id) => set((state) => ({
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
})),
|
||||
|
||||
// Media Actions
|
||||
setMedia: (media) => set({ media }),
|
||||
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
|
||||
updateMedia: (id, updatedMedia) => set((state) => ({
|
||||
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
|
||||
})),
|
||||
removeMedia: (id) => set((state) => ({
|
||||
media: state.media.filter((m) => m.id !== id),
|
||||
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
|
||||
})),
|
||||
|
||||
// Task Actions
|
||||
setTasks: (tasks) => set({ tasks }),
|
||||
updateTask: (taskId, task) => set((state) => ({
|
||||
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
|
||||
})),
|
||||
|
||||
// Sync Actions
|
||||
setSyncStatus: (syncStatus) => set({ syncStatus }),
|
||||
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
|
||||
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
|
||||
|
||||
// Loading Actions
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
}));
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial Project State
|
||||
projects: [],
|
||||
activeProject: null,
|
||||
|
||||
// Initial UI State
|
||||
activeView: 'posts',
|
||||
sidebarVisible: true,
|
||||
panelVisible: false,
|
||||
selectedPostId: null,
|
||||
selectedMediaId: null,
|
||||
|
||||
// Initial Data
|
||||
posts: [],
|
||||
media: [],
|
||||
tasks: [],
|
||||
|
||||
// Initial Sync State
|
||||
syncStatus: 'idle',
|
||||
syncConfigured: false,
|
||||
pendingChanges: { posts: 0, media: 0 },
|
||||
|
||||
// Initial Loading State
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// 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 })),
|
||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
||||
|
||||
// Post Actions
|
||||
setPosts: (posts) => set({ posts }),
|
||||
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
|
||||
updatePost: (id, updatedPost) => set((state) => ({
|
||||
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
|
||||
})),
|
||||
removePost: (id) => set((state) => ({
|
||||
posts: state.posts.filter((p) => p.id !== id),
|
||||
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
||||
})),
|
||||
|
||||
// Media Actions
|
||||
setMedia: (media) => set({ media }),
|
||||
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
|
||||
updateMedia: (id, updatedMedia) => set((state) => ({
|
||||
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
|
||||
})),
|
||||
removeMedia: (id) => set((state) => ({
|
||||
media: state.media.filter((m) => m.id !== id),
|
||||
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
|
||||
})),
|
||||
|
||||
// Task Actions
|
||||
setTasks: (tasks) => set({ tasks }),
|
||||
updateTask: (taskId, task) => set((state) => ({
|
||||
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
|
||||
})),
|
||||
|
||||
// Sync Actions
|
||||
setSyncStatus: (syncStatus) => set({ syncStatus }),
|
||||
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
|
||||
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
|
||||
|
||||
// Loading Actions
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
// Only persist UI state, not data (which is loaded from backend)
|
||||
partialize: (state) => ({
|
||||
activeView: state.activeView,
|
||||
sidebarVisible: state.sidebarVisible,
|
||||
panelVisible: state.panelVisible,
|
||||
selectedPostId: state.selectedPostId,
|
||||
selectedMediaId: state.selectedMediaId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user