feat: more feature implementations
This commit is contained in:
25
VISION.md
25
VISION.md
@@ -84,6 +84,21 @@ This is a short-form article that does not need a full-article-page, because it
|
|||||||
a short comment that is shown after the link. This is meant for link collections and should be rendered
|
a short comment that is shown after the link. This is meant for link collections and should be rendered
|
||||||
in a compact form in the overview pages. More on rendering in the publishing pipelin description.
|
in a compact form in the overview pages. More on rendering in the publishing pipelin description.
|
||||||
|
|
||||||
|
### category "page"
|
||||||
|
|
||||||
|
This is a post that behaves mostly like an article, but is ignored in overview pages, because it is just
|
||||||
|
meant to be linked to menues. So menu editing needs to be able to reference posts of category page
|
||||||
|
and overview templates need to ignore posts of category page. Other than that, they are just like article,
|
||||||
|
so have long form text, short form summary and title. Pages can be assigned different header images that
|
||||||
|
override the project image.
|
||||||
|
|
||||||
|
### Project definition
|
||||||
|
|
||||||
|
A project is not just the collection of posts, media and publish settings, it is also a base project
|
||||||
|
container with title, author and other elements like that. So there needs to be project related settings
|
||||||
|
that allow to give the project a title, set up a main author that can be referenced in metadat and for
|
||||||
|
example set up header images that can be used when publishing.
|
||||||
|
|
||||||
## Migrating
|
## Migrating
|
||||||
|
|
||||||
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old
|
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old
|
||||||
@@ -141,6 +156,12 @@ system and also create blog posts with AI support.
|
|||||||
All SDK tools must also be made available as MCP server that is hosted inside the application, so that I
|
All SDK tools must also be made available as MCP server that is hosted inside the application, so that I
|
||||||
can hook the app into a normal AI coding agent.
|
can hook the app into a normal AI coding agent.
|
||||||
|
|
||||||
|
Also the AI should be available to create summaries just with a button click in the post editor area, so
|
||||||
|
that the summary is filled in based on AI summarization to help speed up the blogging process. Also the
|
||||||
|
AI can be used to generate a good slurl that is not just generically from the title and even the title
|
||||||
|
can be AI-generated from the text. That way the user can focus on writing the core text and if all matches,
|
||||||
|
just accept AI summary and title and post it.
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
Publishing should target static HTML/CSS/JavaScript situations. There must be a asnyc exporter, that will render
|
Publishing should target static HTML/CSS/JavaScript situations. There must be a asnyc exporter, that will render
|
||||||
@@ -168,7 +189,9 @@ maybe even with easy importing from a central bootstrap site or something like t
|
|||||||
Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be
|
Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be
|
||||||
capable of building with this tooling. So we need templates for overview pages and ways to manage menues
|
capable of building with this tooling. So we need templates for overview pages and ways to manage menues
|
||||||
that reference overview pages and structure the menu according to site structure. Also support calendar views
|
that reference overview pages and structure the menu according to site structure. Also support calendar views
|
||||||
to allow users to go to specific months and years of the blog.
|
to allow users to go to specific months and years of the blog. Build up a sensible set of templates that
|
||||||
|
come with a new project, so that the user can start right away without a lot of hassle. Base the templates
|
||||||
|
on the structure of above website, but keep out website title and images, of course.
|
||||||
|
|
||||||
Categories and tags must be able to define a template selection for post templates, so that different types
|
Categories and tags must be able to define a template selection for post templates, so that different types
|
||||||
can be represented differently. this is especially important for the standard categories "article", "picture"
|
can be represented differently. this is especially important for the standard categories "article", "picture"
|
||||||
|
|||||||
1416
package-lock.json
generated
1416
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -43,14 +43,25 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@tiptap/extension-image": "^3.19.0",
|
||||||
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
|
"@tiptap/extension-underline": "^3.19.0",
|
||||||
|
"@tiptap/pm": "^3.19.0",
|
||||||
|
"@tiptap/react": "^3.19.0",
|
||||||
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,12 +190,22 @@ export class DatabaseConnection {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_post_id TEXT NOT NULL,
|
||||||
|
target_post_id TEXT NOT NULL,
|
||||||
|
link_text TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Check if project_id column exists in posts table, add if missing (migration)
|
// Check if project_id column exists in posts table, add if missing (migration)
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ export const settings = sqliteTable('settings', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Post links - tracks internal links between posts
|
||||||
|
export const postLinks = sqliteTable('post_links', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
sourcePostId: text('source_post_id').notNull(), // Post containing the link
|
||||||
|
targetPostId: text('target_post_id').notNull(), // Post being linked to
|
||||||
|
linkText: text('link_text'), // The text of the link
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Types for TypeScript
|
// Types for TypeScript
|
||||||
export type Project = typeof projects.$inferSelect;
|
export type Project = typeof projects.$inferSelect;
|
||||||
export type NewProject = typeof projects.$inferInsert;
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
@@ -83,3 +92,5 @@ export type SyncLogEntry = typeof syncLog.$inferSelect;
|
|||||||
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
|
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
|
||||||
export type Setting = typeof settings.$inferSelect;
|
export type Setting = typeof settings.$inferSelect;
|
||||||
export type NewSetting = typeof settings.$inferInsert;
|
export type NewSetting = typeof settings.$inferInsert;
|
||||||
|
export type PostLink = typeof postLinks.$inferSelect;
|
||||||
|
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media, Media, NewMedia } from '../database/schema';
|
import { media, Media, NewMedia } from '../database/schema';
|
||||||
|
|
||||||
|
// Thumbnail sizes
|
||||||
|
const THUMBNAIL_SIZES = {
|
||||||
|
small: { width: 150, height: 150 },
|
||||||
|
medium: { width: 400, height: 400 },
|
||||||
|
large: { width: 800, height: 800 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||||
import { taskManager, Task } from './TaskManager';
|
import { taskManager, Task } from './TaskManager';
|
||||||
|
|
||||||
export interface MediaData {
|
export interface MediaData {
|
||||||
@@ -88,6 +97,105 @@ export class MediaEngine extends EventEmitter {
|
|||||||
return crypto.createHash('md5').update(buffer).digest('hex');
|
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the thumbnails directory for the current project
|
||||||
|
*/
|
||||||
|
private getThumbnailsDir(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, 'projects', this.currentProjectId, 'thumbnails');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnails for an image file
|
||||||
|
*/
|
||||||
|
async generateThumbnails(mediaId: string, sourcePath: string): Promise<Record<ThumbnailSize, string>> {
|
||||||
|
const thumbnailsDir = this.getThumbnailsDir();
|
||||||
|
await fs.mkdir(thumbnailsDir, { recursive: true });
|
||||||
|
|
||||||
|
const thumbnails: Record<ThumbnailSize, string> = {} as Record<ThumbnailSize, string>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import of sharp (it's a native module)
|
||||||
|
const sharp = (await import('sharp')).default;
|
||||||
|
|
||||||
|
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
||||||
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||||
|
|
||||||
|
await sharp(sourcePath)
|
||||||
|
.resize(dimensions.width, dimensions.height, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.webp({ quality: 80 })
|
||||||
|
.toFile(thumbnailPath);
|
||||||
|
|
||||||
|
thumbnails[size] = thumbnailPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('thumbnailsGenerated', { mediaId, thumbnails });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate thumbnails:', error);
|
||||||
|
// Return empty thumbnails on error - non-critical failure
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbnails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get existing thumbnail paths for a media item
|
||||||
|
*/
|
||||||
|
async getThumbnailPaths(mediaId: string): Promise<Record<ThumbnailSize, string | null>> {
|
||||||
|
const thumbnailsDir = this.getThumbnailsDir();
|
||||||
|
const result: Record<ThumbnailSize, string | null> = {
|
||||||
|
small: null,
|
||||||
|
medium: null,
|
||||||
|
large: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||||
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||||
|
try {
|
||||||
|
await fs.access(thumbnailPath);
|
||||||
|
result[size] = thumbnailPath;
|
||||||
|
} catch {
|
||||||
|
// Thumbnail doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get thumbnail as base64 data URL for renderer
|
||||||
|
*/
|
||||||
|
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
||||||
|
const thumbnailsDir = this.getThumbnailsDir();
|
||||||
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(thumbnailPath);
|
||||||
|
return `data:image/webp;base64,${data.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete thumbnails for a media item
|
||||||
|
*/
|
||||||
|
private async deleteThumbnails(mediaId: string): Promise<void> {
|
||||||
|
const thumbnailsDir = this.getThumbnailsDir();
|
||||||
|
|
||||||
|
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||||
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||||
|
try {
|
||||||
|
await fs.unlink(thumbnailPath);
|
||||||
|
} catch {
|
||||||
|
// Thumbnail doesn't exist, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
||||||
const sidecarPath = `${mediaPath}.meta`;
|
const sidecarPath = `${mediaPath}.meta`;
|
||||||
|
|
||||||
@@ -239,14 +347,30 @@ export class MediaEngine extends EventEmitter {
|
|||||||
// Copy file to media directory
|
// Copy file to media directory
|
||||||
await fs.copyFile(sourcePath, destPath);
|
await fs.copyFile(sourcePath, destPath);
|
||||||
|
|
||||||
|
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
|
||||||
|
let width = metadata?.width;
|
||||||
|
let height = metadata?.height;
|
||||||
|
|
||||||
|
// Get image dimensions using sharp if it's an image
|
||||||
|
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||||
|
try {
|
||||||
|
const sharp = (await import('sharp')).default;
|
||||||
|
const imageMetadata = await sharp(destPath).metadata();
|
||||||
|
width = imageMetadata.width;
|
||||||
|
height = imageMetadata.height;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get image dimensions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mediaData: MediaData = {
|
const mediaData: MediaData = {
|
||||||
id,
|
id,
|
||||||
filename,
|
filename,
|
||||||
originalName,
|
originalName,
|
||||||
mimeType: metadata?.mimeType || this.getMimeType(originalName),
|
mimeType,
|
||||||
size: sourceBuffer.length,
|
size: sourceBuffer.length,
|
||||||
width: metadata?.width,
|
width,
|
||||||
height: metadata?.height,
|
height,
|
||||||
alt: metadata?.alt,
|
alt: metadata?.alt,
|
||||||
caption: metadata?.caption,
|
caption: metadata?.caption,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -257,6 +381,13 @@ export class MediaEngine extends EventEmitter {
|
|||||||
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
||||||
const checksum = this.calculateChecksum(sourceBuffer);
|
const checksum = this.calculateChecksum(sourceBuffer);
|
||||||
|
|
||||||
|
// Generate thumbnails for images (async, non-blocking)
|
||||||
|
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||||
|
this.generateThumbnails(id, destPath).catch(err => {
|
||||||
|
console.error('Failed to generate thumbnails:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const dbMedia: NewMedia = {
|
const dbMedia: NewMedia = {
|
||||||
id: mediaData.id,
|
id: mediaData.id,
|
||||||
projectId: this.currentProjectId,
|
projectId: this.currentProjectId,
|
||||||
@@ -339,6 +470,9 @@ export class MediaEngine extends EventEmitter {
|
|||||||
// File might not exist
|
// File might not exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete thumbnails
|
||||||
|
await this.deleteThumbnails(id);
|
||||||
|
|
||||||
await db.delete(media).where(eq(media.id, id));
|
await db.delete(media).where(eq(media.id, id));
|
||||||
|
|
||||||
this.emit('mediaDeleted', id);
|
this.emit('mediaDeleted', id);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import matter from 'gray-matter';
|
|||||||
import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, Post, NewPost } from '../database/schema';
|
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
||||||
import { taskManager, Task } from './TaskManager';
|
import { taskManager, Task } from './TaskManager';
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
@@ -289,6 +289,11 @@ export class PostEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update post links if content changed
|
||||||
|
if (data.content) {
|
||||||
|
await this.updatePostLinks(id, updated.content);
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('postUpdated', updated);
|
this.emit('postUpdated', updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -635,6 +640,140 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
await taskManager.runTask(task);
|
await taskManager.runTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract internal post links from content (links to other posts in the blog)
|
||||||
|
*/
|
||||||
|
extractInternalLinks(content: string): { slug: string; text: string }[] {
|
||||||
|
const links: { slug: string; text: string }[] = [];
|
||||||
|
|
||||||
|
// Match markdown links: [text](/posts/slug) or [text](/year/month/slug)
|
||||||
|
const markdownLinkRegex = /\[([^\]]+)\]\(\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?\)/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||||
|
links.push({ text: match[1], slug: match[2] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match HTML links: <a href="/posts/slug">text</a>
|
||||||
|
const htmlLinkRegex = /<a[^>]+href=["']\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?["'][^>]*>([^<]+)<\/a>/gi;
|
||||||
|
while ((match = htmlLinkRegex.exec(content)) !== null) {
|
||||||
|
links.push({ text: match[2], slug: match[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update post links in the database based on content analysis
|
||||||
|
*/
|
||||||
|
async updatePostLinks(postId: string, content: string): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const extractedLinks = this.extractInternalLinks(content);
|
||||||
|
|
||||||
|
// Delete existing links from this post
|
||||||
|
await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId));
|
||||||
|
|
||||||
|
if (extractedLinks.length === 0) return;
|
||||||
|
|
||||||
|
// Get all posts to resolve slugs to IDs
|
||||||
|
const allPosts = await db.select({ id: posts.id, slug: posts.slug })
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
const slugToId = new Map(allPosts.map(p => [p.slug, p.id]));
|
||||||
|
|
||||||
|
// Insert new links
|
||||||
|
for (const link of extractedLinks) {
|
||||||
|
const targetId = slugToId.get(link.slug);
|
||||||
|
if (targetId && targetId !== postId) {
|
||||||
|
await db.insert(postLinks).values({
|
||||||
|
id: uuidv4(),
|
||||||
|
sourcePostId: postId,
|
||||||
|
targetPostId: targetId,
|
||||||
|
linkText: link.text,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get posts that link TO the specified post ("linked by")
|
||||||
|
*/
|
||||||
|
async getLinkedBy(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
const links = await db
|
||||||
|
.select({
|
||||||
|
sourcePostId: postLinks.sourcePostId,
|
||||||
|
linkText: postLinks.linkText,
|
||||||
|
})
|
||||||
|
.from(postLinks)
|
||||||
|
.where(eq(postLinks.targetPostId, postId));
|
||||||
|
|
||||||
|
if (links.length === 0) return [];
|
||||||
|
|
||||||
|
const sourceIds = links.map(l => l.sourcePostId);
|
||||||
|
const sourcePosts = await db
|
||||||
|
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
return sourcePosts.filter(p => sourceIds.includes(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get posts that the specified post links TO ("links to")
|
||||||
|
*/
|
||||||
|
async getLinksTo(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
const links = await db
|
||||||
|
.select({
|
||||||
|
targetPostId: postLinks.targetPostId,
|
||||||
|
linkText: postLinks.linkText,
|
||||||
|
})
|
||||||
|
.from(postLinks)
|
||||||
|
.where(eq(postLinks.sourcePostId, postId));
|
||||||
|
|
||||||
|
if (links.length === 0) return [];
|
||||||
|
|
||||||
|
const targetIds = links.map(l => l.targetPostId);
|
||||||
|
const targetPosts = await db
|
||||||
|
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
return targetPosts.filter(p => targetIds.includes(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild all post links from content analysis
|
||||||
|
*/
|
||||||
|
async rebuildAllPostLinks(): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
// Clear all existing links
|
||||||
|
await db.delete(postLinks);
|
||||||
|
|
||||||
|
// Get all posts
|
||||||
|
const allPosts = await db
|
||||||
|
.select({ id: posts.id, filePath: posts.filePath })
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
for (const post of allPosts) {
|
||||||
|
try {
|
||||||
|
const fileContent = await fs.readFile(post.filePath, 'utf-8');
|
||||||
|
const { content } = matter(fileContent);
|
||||||
|
await this.updatePostLinks(post.id, content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update links for post ${post.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('postLinksRebuilt');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ipcMain, dialog, shell } from 'electron';
|
import { ipcMain, dialog, shell } from 'electron';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
|
import { media } from '../database/schema';
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Project Handlers ============
|
// ============ Project Handlers ============
|
||||||
@@ -126,6 +128,21 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getPostsByYearMonth();
|
return engine.getPostsByYearMonth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getLinksTo(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:getLinkedBy', async (_, id: string) => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getLinkedBy(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:rebuildLinks', async () => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.rebuildAllPostLinks();
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Media Handlers ============
|
// ============ Media Handlers ============
|
||||||
|
|
||||||
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
||||||
@@ -187,6 +204,24 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.rebuildDatabaseFromFiles();
|
return engine.rebuildDatabaseFromFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
return engine.getThumbnailDataUrl(id, size || 'small');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('media:regenerateThumbnails', async (_, id: string) => {
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
const mediaItem = await engine.getMedia(id);
|
||||||
|
if (mediaItem && mediaItem.mimeType.startsWith('image/')) {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||||
|
if (dbMedia) {
|
||||||
|
return engine.generateThumbnails(id, dbMedia.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Sync Handlers ============
|
// ============ Sync Handlers ============
|
||||||
|
|
||||||
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
|
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
||||||
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
||||||
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
||||||
|
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
|
||||||
|
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
|
||||||
|
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
@@ -41,6 +44,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
||||||
getAll: () => ipcRenderer.invoke('media:getAll'),
|
getAll: () => ipcRenderer.invoke('media:getAll'),
|
||||||
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||||
|
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||||
|
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
@@ -107,6 +112,9 @@ export interface ElectronAPI {
|
|||||||
getTags: () => Promise<string[]>;
|
getTags: () => Promise<string[]>;
|
||||||
getCategories: () => Promise<string[]>;
|
getCategories: () => Promise<string[]>;
|
||||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||||
|
getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||||
|
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||||
|
rebuildLinks: () => Promise<void>;
|
||||||
};
|
};
|
||||||
media: {
|
media: {
|
||||||
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
||||||
|
|||||||
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);
|
color: var(--vscode-button-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-button:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-preview {
|
.editor-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
@@ -196,6 +212,63 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-preview .preview-content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview h1,
|
||||||
|
.editor-preview h2,
|
||||||
|
.editor-preview h3 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview h1 { font-size: 2em; }
|
||||||
|
.editor-preview h2 { font-size: 1.5em; }
|
||||||
|
.editor-preview h3 { font-size: 1.25em; }
|
||||||
|
|
||||||
|
.editor-preview code {
|
||||||
|
background-color: var(--vscode-textCodeBlock-background);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview pre {
|
||||||
|
background-color: var(--vscode-textCodeBlock-background);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview blockquote {
|
||||||
|
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-left: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview a {
|
||||||
|
color: var(--vscode-textLink-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview a:hover {
|
||||||
|
color: var(--vscode-textLink-activeForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-field-row {
|
.editor-field-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -2,8 +2,43 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { useAppStore, PostData } from '../../store';
|
import { useAppStore, PostData } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
|
import { WysiwygEditor } from '../WysiwygEditor';
|
||||||
|
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||||
|
import { PostLinks } from '../PostLinks';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
|
// Simple markdown to HTML converter for preview
|
||||||
|
const markdownToHtml = (markdown: string): string => {
|
||||||
|
return markdown
|
||||||
|
// Escape HTML
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.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 {
|
interface PostEditorProps {
|
||||||
post: PostData;
|
post: PostData;
|
||||||
}
|
}
|
||||||
@@ -15,9 +50,14 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const [tags, setTags] = useState(post.tags.join(', '));
|
const [tags, setTags] = useState(post.tags.join(', '));
|
||||||
const [categories, setCategories] = useState(post.categories.join(', '));
|
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown');
|
const [editorMode, setEditorMode] = useState<EditorMode>('wysiwyg');
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
const editorRef = useRef<unknown>(null);
|
const editorRef = useRef<unknown>(null);
|
||||||
|
|
||||||
|
// Extract images from content for lightbox
|
||||||
|
const images = useMarkdownImages(content);
|
||||||
|
|
||||||
// Reset when post changes
|
// Reset when post changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(post.title);
|
setTitle(post.title);
|
||||||
@@ -200,27 +240,59 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PostLinks
|
||||||
|
postId={post.id}
|
||||||
|
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-body">
|
<div className="editor-body">
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<label>Content (Markdown)</label>
|
<label>Content</label>
|
||||||
<div className="editor-mode-toggle">
|
<div className="editor-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||||
|
onClick={() => setEditorMode('wysiwyg')}
|
||||||
|
title="Visual editor"
|
||||||
|
>
|
||||||
|
Visual
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={editorMode === 'markdown' ? 'active' : ''}
|
className={editorMode === 'markdown' ? 'active' : ''}
|
||||||
onClick={() => setEditorMode('markdown')}
|
onClick={() => setEditorMode('markdown')}
|
||||||
|
title="Markdown source"
|
||||||
>
|
>
|
||||||
Markdown
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={editorMode === 'preview' ? 'active' : ''}
|
className={editorMode === 'preview' ? 'active' : ''}
|
||||||
onClick={() => setEditorMode('preview')}
|
onClick={() => setEditorMode('preview')}
|
||||||
|
title="Read-only preview"
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="gallery-button"
|
||||||
|
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
||||||
|
title={`View ${images.length} image(s)`}
|
||||||
|
>
|
||||||
|
📷 {images.length}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editorMode === 'markdown' ? (
|
|
||||||
|
{editorMode === 'wysiwyg' && (
|
||||||
|
<WysiwygEditor
|
||||||
|
content={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="Start writing..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editorMode === 'markdown' && (
|
||||||
<MonacoEditor
|
<MonacoEditor
|
||||||
height="100%"
|
height="100%"
|
||||||
defaultLanguage="markdown"
|
defaultLanguage="markdown"
|
||||||
@@ -244,13 +316,25 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
cursorBlinking: 'smooth',
|
cursorBlinking: 'smooth',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{editorMode === 'preview' && (
|
||||||
<div className="editor-preview markdown-body">
|
<div className="editor-preview markdown-body">
|
||||||
{/* Simple markdown preview - could be enhanced with a proper renderer */}
|
<div
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre>
|
className="preview-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox for viewing images in content */}
|
||||||
|
<Lightbox
|
||||||
|
images={images}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
isOpen={lightboxOpen}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-footer">
|
<div className="editor-footer">
|
||||||
|
|||||||
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 {
|
.sidebar-item {
|
||||||
padding: 6px 12px 6px 24px;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px 6px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,39 @@
|
|||||||
border-left-color: var(--vscode-focusBorder);
|
border-left-color: var(--vscode-focusBorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-type-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post type specific styling */
|
||||||
|
.sidebar-item.post-type-picture {
|
||||||
|
background: linear-gradient(90deg, rgba(139, 92, 246, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.post-type-aside {
|
||||||
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.post-type-quote {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.post-type-link {
|
||||||
|
background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.post-type-video {
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-item-title {
|
.sidebar-item-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--vscode-sideBar-foreground);
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ const formatFileSize = (bytes: number) => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get post type icon based on categories
|
||||||
|
const getPostTypeIcon = (categories: string[]): { icon: string; type: string } => {
|
||||||
|
const lowerCategories = categories.map(c => c.toLowerCase());
|
||||||
|
if (lowerCategories.includes('picture') || lowerCategories.includes('photo') || lowerCategories.includes('image')) {
|
||||||
|
return { icon: '🖼️', type: 'picture' };
|
||||||
|
}
|
||||||
|
if (lowerCategories.includes('aside') || lowerCategories.includes('note') || lowerCategories.includes('quick')) {
|
||||||
|
return { icon: '📝', type: 'aside' };
|
||||||
|
}
|
||||||
|
if (lowerCategories.includes('link') || lowerCategories.includes('bookmark')) {
|
||||||
|
return { icon: '🔗', type: 'link' };
|
||||||
|
}
|
||||||
|
if (lowerCategories.includes('video')) {
|
||||||
|
return { icon: '🎬', type: 'video' };
|
||||||
|
}
|
||||||
|
if (lowerCategories.includes('quote')) {
|
||||||
|
return { icon: '💬', type: 'quote' };
|
||||||
|
}
|
||||||
|
// Default to article
|
||||||
|
return { icon: '📄', type: 'article' };
|
||||||
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
interface CalendarViewProps {
|
interface CalendarViewProps {
|
||||||
@@ -399,16 +421,22 @@ const PostsList: React.FC = () => {
|
|||||||
Drafts ({groupedPosts.draft.length})
|
Drafts ({groupedPosts.draft.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-list">
|
<div className="sidebar-list">
|
||||||
{groupedPosts.draft.map(post => (
|
{groupedPosts.draft.map(post => {
|
||||||
|
const postType = getPostTypeIcon(post.categories);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
key={post.id}
|
||||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||||
onClick={() => setSelectedPost(post.id)}
|
onClick={() => setSelectedPost(post.id)}
|
||||||
>
|
>
|
||||||
|
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||||
|
<div className="sidebar-item-content">
|
||||||
<div className="sidebar-item-title">{post.title}</div>
|
<div className="sidebar-item-title">{post.title}</div>
|
||||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -420,16 +448,22 @@ const PostsList: React.FC = () => {
|
|||||||
Published ({groupedPosts.published.length})
|
Published ({groupedPosts.published.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-list">
|
<div className="sidebar-list">
|
||||||
{groupedPosts.published.map(post => (
|
{groupedPosts.published.map(post => {
|
||||||
|
const postType = getPostTypeIcon(post.categories);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
key={post.id}
|
||||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||||
onClick={() => setSelectedPost(post.id)}
|
onClick={() => setSelectedPost(post.id)}
|
||||||
>
|
>
|
||||||
|
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||||
|
<div className="sidebar-item-content">
|
||||||
<div className="sidebar-item-title">{post.title}</div>
|
<div className="sidebar-item-title">{post.title}</div>
|
||||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
|
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -441,16 +475,22 @@ const PostsList: React.FC = () => {
|
|||||||
Archived ({groupedPosts.archived.length})
|
Archived ({groupedPosts.archived.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-list">
|
<div className="sidebar-list">
|
||||||
{groupedPosts.archived.map(post => (
|
{groupedPosts.archived.map(post => {
|
||||||
|
const postType = getPostTypeIcon(post.categories);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
key={post.id}
|
||||||
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
|
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
|
||||||
onClick={() => setSelectedPost(post.id)}
|
onClick={() => setSelectedPost(post.id)}
|
||||||
>
|
>
|
||||||
|
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||||
|
<div className="sidebar-item-content">
|
||||||
<div className="sidebar-item-title">{post.title}</div>
|
<div className="sidebar-item-title">{post.title}</div>
|
||||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 { Panel } from './Panel';
|
||||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||||
export { ProjectSelector } from './ProjectSelector';
|
export { ProjectSelector } from './ProjectSelector';
|
||||||
|
export { WysiwygEditor } from './WysiwygEditor';
|
||||||
|
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
||||||
|
export { TaskPopup } from './TaskPopup';
|
||||||
|
export { ResizablePanel } from './ResizablePanel';
|
||||||
|
export { CredentialsPanel } from './CredentialsPanel';
|
||||||
|
export { PostLinks } from './PostLinks';
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
// Storage key for persisted state
|
||||||
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
@@ -113,7 +117,9 @@ interface AppState {
|
|||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
// Initial Project State
|
// Initial Project State
|
||||||
projects: [],
|
projects: [],
|
||||||
activeProject: null,
|
activeProject: null,
|
||||||
@@ -193,4 +199,17 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
// Loading Actions
|
// Loading Actions
|
||||||
setLoading: (isLoading) => set({ isLoading }),
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
}));
|
}),
|
||||||
|
{
|
||||||
|
name: STORAGE_KEY,
|
||||||
|
// Only persist UI state, not data (which is loaded from backend)
|
||||||
|
partialize: (state) => ({
|
||||||
|
activeView: state.activeView,
|
||||||
|
sidebarVisible: state.sidebarVisible,
|
||||||
|
panelVisible: state.panelVisible,
|
||||||
|
selectedPostId: state.selectedPostId,
|
||||||
|
selectedMediaId: state.selectedMediaId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user