feat: gallery macro
This commit is contained in:
@@ -182,6 +182,15 @@ export class DatabaseConnection {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
post_id TEXT NOT NULL,
|
||||||
|
media_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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);
|
||||||
@@ -190,6 +199,9 @@ export class DatabaseConnection {
|
|||||||
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_source ON post_links(source_post_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
|
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_media_post ON post_media(post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx ON post_media(post_id, media_id);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug);
|
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ export const postLinks = sqliteTable('post_links', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Post-Media links - tracks which media files are linked to which posts
|
||||||
|
export const postMedia = sqliteTable('post_media', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
projectId: text('project_id').notNull(),
|
||||||
|
postId: text('post_id').notNull(),
|
||||||
|
mediaId: text('media_id').notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0), // For ordering media within a post
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Composite unique index: a media can only be linked once to a post
|
||||||
|
postMediaIdx: uniqueIndex('post_media_post_media_idx').on(table.postId, table.mediaId),
|
||||||
|
}));
|
||||||
|
|
||||||
// Tags table - stores tag metadata with optional colors
|
// Tags table - stores tag metadata with optional colors
|
||||||
export const tags = sqliteTable('tags', {
|
export const tags = sqliteTable('tags', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -143,6 +156,8 @@ 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 PostLink = typeof postLinks.$inferSelect;
|
||||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||||
|
export type PostMediaLink = typeof postMedia.$inferSelect;
|
||||||
|
export type NewPostMediaLink = typeof postMedia.$inferInsert;
|
||||||
export type Tag = typeof tags.$inferSelect;
|
export type Tag = typeof tags.$inferSelect;
|
||||||
export type NewTag = typeof tags.$inferInsert;
|
export type NewTag = typeof tags.$inferInsert;
|
||||||
export type ChatConversation = typeof chatConversations.$inferSelect;
|
export type ChatConversation = typeof chatConversations.$inferSelect;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface MediaData {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
linkedPostIds?: string[]; // Posts this media is linked to
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaMetadata {
|
export interface MediaMetadata {
|
||||||
@@ -45,6 +46,7 @@ export interface MediaMetadata {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaEngine extends EventEmitter {
|
export class MediaEngine extends EventEmitter {
|
||||||
@@ -227,6 +229,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
createdAt: mediaData.createdAt.toISOString(),
|
createdAt: mediaData.createdAt.toISOString(),
|
||||||
updatedAt: mediaData.updatedAt.toISOString(),
|
updatedAt: mediaData.updatedAt.toISOString(),
|
||||||
tags: mediaData.tags,
|
tags: mediaData.tags,
|
||||||
|
linkedPostIds: mediaData.linkedPostIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write YAML-like format consistent with posts
|
// Write YAML-like format consistent with posts
|
||||||
@@ -246,6 +249,9 @@ export class MediaEngine extends EventEmitter {
|
|||||||
lines.push(`createdAt: ${metadata.createdAt}`);
|
lines.push(`createdAt: ${metadata.createdAt}`);
|
||||||
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
||||||
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
|
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
|
||||||
|
if (metadata.linkedPostIds && metadata.linkedPostIds.length > 0) {
|
||||||
|
lines.push(`linkedPostIds: [${metadata.linkedPostIds.map(id => `"${id}"`).join(', ')}]`);
|
||||||
|
}
|
||||||
lines.push('---');
|
lines.push('---');
|
||||||
|
|
||||||
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
||||||
@@ -267,6 +273,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
const metadata: Partial<MediaMetadata> = {
|
const metadata: Partial<MediaMetadata> = {
|
||||||
tags: [],
|
tags: [],
|
||||||
|
linkedPostIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -316,14 +323,24 @@ export class MediaEngine extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
case 'tags':
|
case 'tags':
|
||||||
// Parse array format: ["tag1", "tag2"]
|
// Parse array format: ["tag1", "tag2"]
|
||||||
const match = value.match(/\[(.*)\]/);
|
const tagsMatch = value.match(/\[(.*)\]/);
|
||||||
if (match) {
|
if (tagsMatch) {
|
||||||
metadata.tags = match[1]
|
metadata.tags = tagsMatch[1]
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(t => t.trim().replace(/"/g, ''))
|
.map(t => t.trim().replace(/"/g, ''))
|
||||||
.filter(t => t.length > 0);
|
.filter(t => t.length > 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'linkedPostIds':
|
||||||
|
// Parse array format: ["postId1", "postId2"]
|
||||||
|
const postIdsMatch = value.match(/\[(.*)\]/);
|
||||||
|
if (postIdsMatch) {
|
||||||
|
metadata.linkedPostIds = postIdsMatch[1]
|
||||||
|
.split(',')
|
||||||
|
.map(id => id.trim().replace(/"/g, ''))
|
||||||
|
.filter(id => id.length > 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
295
src/main/engine/PostMediaEngine.ts
Normal file
295
src/main/engine/PostMediaEngine.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* PostMediaEngine
|
||||||
|
*
|
||||||
|
* Manages the relationship between posts and media files.
|
||||||
|
* Handles linking, unlinking, ordering, and querying post-media associations.
|
||||||
|
*
|
||||||
|
* Data is persisted in two places:
|
||||||
|
* 1. postMedia junction table in the database (for querying)
|
||||||
|
* 2. linkedPostIds field in media sidecar files (source of truth for rebuild)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { getDatabase } from '../database';
|
||||||
|
import { postMedia, PostMediaLink, NewPostMediaLink } from '../database/schema';
|
||||||
|
import { getMediaEngine, MediaData } from './MediaEngine';
|
||||||
|
|
||||||
|
export interface PostMediaLinkData {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
postId: string;
|
||||||
|
mediaId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let postMediaEngineInstance: PostMediaEngine | null = null;
|
||||||
|
|
||||||
|
export class PostMediaEngine extends EventEmitter {
|
||||||
|
private currentProjectId: string = 'default';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current project context
|
||||||
|
*/
|
||||||
|
setProjectContext(projectId: string): void {
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a media file to a post
|
||||||
|
*/
|
||||||
|
async linkMediaToPost(postId: string, mediaId: string): Promise<PostMediaLinkData> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
// Get current highest sortOrder for this post
|
||||||
|
const existingLinks = await this.getLinkedMediaForPost(postId);
|
||||||
|
const maxSortOrder = existingLinks.length > 0
|
||||||
|
? Math.max(...existingLinks.map(l => l.sortOrder))
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const link: NewPostMediaLink = {
|
||||||
|
id: uuidv4(),
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
postId,
|
||||||
|
mediaId,
|
||||||
|
sortOrder: maxSortOrder + 1,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(postMedia).values(link);
|
||||||
|
|
||||||
|
// Update the media sidecar to include this post
|
||||||
|
const media = await getMediaEngine().getMedia(mediaId);
|
||||||
|
if (media) {
|
||||||
|
const linkedPostIds = media.linkedPostIds || [];
|
||||||
|
if (!linkedPostIds.includes(postId)) {
|
||||||
|
await getMediaEngine().updateMedia(mediaId, {
|
||||||
|
linkedPostIds: [...linkedPostIds, postId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkData: PostMediaLinkData = {
|
||||||
|
id: link.id,
|
||||||
|
projectId: link.projectId,
|
||||||
|
postId: link.postId,
|
||||||
|
mediaId: link.mediaId,
|
||||||
|
sortOrder: link.sortOrder ?? 0,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('mediaLinked', linkData);
|
||||||
|
return linkData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a media file from a post
|
||||||
|
*/
|
||||||
|
async unlinkMediaFromPost(postId: string, mediaId: string): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
await db.delete(postMedia).where(
|
||||||
|
and(
|
||||||
|
eq(postMedia.postId, postId),
|
||||||
|
eq(postMedia.mediaId, mediaId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the media sidecar to remove this post
|
||||||
|
const media = await getMediaEngine().getMedia(mediaId);
|
||||||
|
if (media) {
|
||||||
|
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
|
||||||
|
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('mediaUnlinked', { postId, mediaId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all media linked to a post, ordered by sortOrder
|
||||||
|
*/
|
||||||
|
async getLinkedMediaForPost(postId: string): Promise<PostMediaLinkData[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
const links = await db
|
||||||
|
.select()
|
||||||
|
.from(postMedia)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(postMedia.projectId, this.currentProjectId),
|
||||||
|
eq(postMedia.postId, postId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(postMedia.sortOrder));
|
||||||
|
|
||||||
|
return links.map(this.mapToLinkData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all posts linked to a media file
|
||||||
|
*/
|
||||||
|
async getLinkedPostsForMedia(mediaId: string): Promise<PostMediaLinkData[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
const links = await db
|
||||||
|
.select()
|
||||||
|
.from(postMedia)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(postMedia.projectId, this.currentProjectId),
|
||||||
|
eq(postMedia.mediaId, mediaId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return links.map(this.mapToLinkData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder media within a post
|
||||||
|
* @param postId The post ID
|
||||||
|
* @param mediaIds Array of media IDs in the new desired order
|
||||||
|
*/
|
||||||
|
async reorderMediaForPost(postId: string, mediaIds: string[]): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
// Update each media's sortOrder based on its position in the array
|
||||||
|
for (let i = 0; i < mediaIds.length; i++) {
|
||||||
|
await db.update(postMedia)
|
||||||
|
.set({ sortOrder: i })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(postMedia.postId, postId),
|
||||||
|
eq(postMedia.mediaId, mediaIds[i])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('mediaReordered', { postId, mediaIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the junction table from media sidecar files.
|
||||||
|
* This is called during rebuild operations when the database needs to be
|
||||||
|
* reconstructed from filesystem source of truth.
|
||||||
|
*/
|
||||||
|
async rebuildFromSidecars(): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
console.log('[PostMediaEngine] Rebuilding post-media links from sidecars...');
|
||||||
|
|
||||||
|
// Clear existing links for this project
|
||||||
|
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
// Get all media with their linkedPostIds
|
||||||
|
const allMedia = await getMediaEngine().getAllMedia();
|
||||||
|
|
||||||
|
let linksCreated = 0;
|
||||||
|
for (const media of allMedia) {
|
||||||
|
const linkedPostIds = media.linkedPostIds || [];
|
||||||
|
|
||||||
|
for (let i = 0; i < linkedPostIds.length; i++) {
|
||||||
|
const postId = linkedPostIds[i];
|
||||||
|
const linkId = uuidv4();
|
||||||
|
|
||||||
|
await db.insert(postMedia).values({
|
||||||
|
id: linkId,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
postId,
|
||||||
|
mediaId: media.id,
|
||||||
|
sortOrder: i, // Preserve order from sidecar
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
linksCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PostMediaEngine] Rebuilt ${linksCreated} post-media links`);
|
||||||
|
this.emit('rebuilt', { linksCreated });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import media from a file path and link it to a post.
|
||||||
|
* This is a convenience method that combines import + link.
|
||||||
|
*/
|
||||||
|
async importMediaForPost(postId: string, sourcePath: string): Promise<PostMediaLinkData> {
|
||||||
|
// Import the media file
|
||||||
|
const importedMedia = await getMediaEngine().importMedia(sourcePath);
|
||||||
|
|
||||||
|
// Link it to the post
|
||||||
|
return this.linkMediaToPost(postId, importedMedia.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get linked media with full media data
|
||||||
|
*/
|
||||||
|
async getLinkedMediaDataForPost(postId: string): Promise<Array<PostMediaLinkData & { media: MediaData }>> {
|
||||||
|
const links = await this.getLinkedMediaForPost(postId);
|
||||||
|
|
||||||
|
const result: Array<PostMediaLinkData & { media: MediaData }> = [];
|
||||||
|
for (const link of links) {
|
||||||
|
const media = await getMediaEngine().getMedia(link.mediaId);
|
||||||
|
if (media) {
|
||||||
|
result.push({ ...link, media });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media is linked to a post
|
||||||
|
*/
|
||||||
|
async isMediaLinkedToPost(postId: string, mediaId: string): Promise<boolean> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
const link = await db
|
||||||
|
.select()
|
||||||
|
.from(postMedia)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(postMedia.postId, postId),
|
||||||
|
eq(postMedia.mediaId, mediaId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return link.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map database row to PostMediaLinkData
|
||||||
|
*/
|
||||||
|
private mapToLinkData(row: PostMediaLink): PostMediaLinkData {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.projectId,
|
||||||
|
postId: row.postId,
|
||||||
|
mediaId: row.mediaId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton PostMediaEngine instance
|
||||||
|
*/
|
||||||
|
export function getPostMediaEngine(): PostMediaEngine {
|
||||||
|
if (!postMediaEngineInstance) {
|
||||||
|
postMediaEngineInstance = new PostMediaEngine();
|
||||||
|
}
|
||||||
|
return postMediaEngineInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton for convenience
|
||||||
|
export const postMediaEngine = getPostMediaEngine();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
||||||
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine';
|
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine';
|
||||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
||||||
|
export { PostMediaEngine, getPostMediaEngine, postMediaEngine, type PostMediaLinkData } from './PostMediaEngine';
|
||||||
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
||||||
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
||||||
export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';
|
export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../
|
|||||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { getMetaEngine } from '../engine/MetaEngine';
|
import { getMetaEngine } from '../engine/MetaEngine';
|
||||||
import { getTagEngine } from '../engine/TagEngine';
|
import { getTagEngine } from '../engine/TagEngine';
|
||||||
|
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||||
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';
|
import { media } from '../database/schema';
|
||||||
@@ -77,6 +78,8 @@ export function registerIpcHandlers(): void {
|
|||||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||||
metaEngine.setProjectContext(project.id);
|
metaEngine.setProjectContext(project.id);
|
||||||
tagEngine.setProjectContext(project.id);
|
tagEngine.setProjectContext(project.id);
|
||||||
|
const postMediaEngine = getPostMediaEngine();
|
||||||
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
// Sync meta on startup
|
// Sync meta on startup
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
@@ -101,6 +104,8 @@ export function registerIpcHandlers(): void {
|
|||||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||||
metaEngine.setProjectContext(project.id);
|
metaEngine.setProjectContext(project.id);
|
||||||
tagEngine.setProjectContext(project.id);
|
tagEngine.setProjectContext(project.id);
|
||||||
|
const postMediaEngine = getPostMediaEngine();
|
||||||
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
// Sync meta on project switch
|
// Sync meta on project switch
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
@@ -668,6 +673,53 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.syncTagsFromPosts();
|
return engine.syncTagsFromPosts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Post-Media Link Handlers ============
|
||||||
|
|
||||||
|
safeHandle('postMedia:link', async (_, postId: string, mediaId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.linkMediaToPost(postId, mediaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:unlink', async (_, postId: string, mediaId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.unlinkMediaFromPost(postId, mediaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:getForPost', async (_, postId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.getLinkedMediaForPost(postId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:getForMedia', async (_, mediaId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.getLinkedPostsForMedia(mediaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:getMediaDataForPost', async (_, postId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.getLinkedMediaDataForPost(postId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:reorder', async (_, postId: string, mediaIds: string[]) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.reorderMediaForPost(postId, mediaIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:isLinked', async (_, postId: string, mediaId: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.isMediaLinkedToPost(postId, mediaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:import', async (_, postId: string, filePath: string) => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.importMediaForPost(postId, filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('postMedia:rebuild', async () => {
|
||||||
|
const engine = getPostMediaEngine();
|
||||||
|
return engine.rebuildFromSidecars();
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Event Forwarding ============
|
// ============ Event Forwarding ============
|
||||||
|
|
||||||
// Forward engine events to renderer
|
// Forward engine events to renderer
|
||||||
@@ -677,6 +729,7 @@ export function registerIpcHandlers(): void {
|
|||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
|
const postMediaEngine = getPostMediaEngine();
|
||||||
|
|
||||||
const forwardEvent = (eventName: string) => {
|
const forwardEvent = (eventName: string) => {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
@@ -713,6 +766,11 @@ export function registerIpcHandlers(): void {
|
|||||||
tagEngine.on('tagsMerged', forwardEvent('tags:merged'));
|
tagEngine.on('tagsMerged', forwardEvent('tags:merged'));
|
||||||
tagEngine.on('tagsSynced', forwardEvent('tags:synced'));
|
tagEngine.on('tagsSynced', forwardEvent('tags:synced'));
|
||||||
|
|
||||||
|
postMediaEngine.on('mediaLinked', forwardEvent('postMedia:linked'));
|
||||||
|
postMediaEngine.on('mediaUnlinked', forwardEvent('postMedia:unlinked'));
|
||||||
|
postMediaEngine.on('mediaReordered', forwardEvent('postMedia:reordered'));
|
||||||
|
postMediaEngine.on('rebuilt', forwardEvent('postMedia:rebuilt'));
|
||||||
|
|
||||||
syncEngine.on('syncStarted', forwardEvent('sync:started'));
|
syncEngine.on('syncStarted', forwardEvent('sync:started'));
|
||||||
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
||||||
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Post-Media Links
|
||||||
|
postMedia: {
|
||||||
|
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
|
||||||
|
unlink: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:unlink', postId, mediaId),
|
||||||
|
getForPost: (postId: string) => ipcRenderer.invoke('postMedia:getForPost', postId),
|
||||||
|
getForMedia: (mediaId: string) => ipcRenderer.invoke('postMedia:getForMedia', mediaId),
|
||||||
|
getMediaDataForPost: (postId: string) => ipcRenderer.invoke('postMedia:getMediaDataForPost', postId),
|
||||||
|
reorder: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:reorder', postId, mediaIds),
|
||||||
|
isLinked: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:isLinked', postId, mediaId),
|
||||||
|
import: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:import', postId, filePath),
|
||||||
|
rebuild: () => ipcRenderer.invoke('postMedia:rebuild'),
|
||||||
|
},
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
sync: {
|
sync: {
|
||||||
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
|
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
|
||||||
|
|||||||
@@ -606,3 +606,187 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Linked Posts Section in Media Editor */
|
||||||
|
.linked-posts-section label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-link-btn {
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-link-btn:hover {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-picker {
|
||||||
|
background: var(--vscode-dropdown-background);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-picker-list {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-picker-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-picker-item:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-picker-more {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-posts,
|
||||||
|
.no-linked-posts {
|
||||||
|
padding: 12px 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-posts-list {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-title {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-title:hover {
|
||||||
|
color: var(--vscode-textLink-foreground);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-item .unlink-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-item:hover .unlink-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-post-item .unlink-btn:hover {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery Macro Styles for Preview */
|
||||||
|
.macro-gallery {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-container {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
|
||||||
|
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.macro-gallery.gallery-cols-3 .gallery-container { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.macro-gallery.gallery-cols-4 .gallery-container { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.macro-gallery.gallery-cols-5 .gallery-container { grid-template-columns: repeat(5, 1fr); }
|
||||||
|
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, 1fr); }
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-loading,
|
||||||
|
.gallery-empty,
|
||||||
|
.gallery-error {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-error {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-caption {
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-error {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
background: var(--vscode-inputValidation-errorBackground);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-loading {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { showToast } from '../Toast';
|
|||||||
import { MilkdownEditor } from '../MilkdownEditor';
|
import { MilkdownEditor } from '../MilkdownEditor';
|
||||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||||
import { PostLinks } from '../PostLinks';
|
import { PostLinks } from '../PostLinks';
|
||||||
|
import { LinkedMediaPanel } from '../LinkedMediaPanel';
|
||||||
import { ErrorModal } from '../ErrorModal';
|
import { ErrorModal } from '../ErrorModal';
|
||||||
import { SettingsView } from '../SettingsView';
|
import { SettingsView } from '../SettingsView';
|
||||||
import { TagsView } from '../TagsView';
|
import { TagsView } from '../TagsView';
|
||||||
import { TagInput } from '../TagInput';
|
import { TagInput } from '../TagInput';
|
||||||
import { ChatPanel } from '../ChatPanel';
|
import { ChatPanel } from '../ChatPanel';
|
||||||
import { AutoSaveManager } from '../../utils';
|
import { AutoSaveManager } from '../../utils';
|
||||||
|
import { parseMacros, getMacro } from '../../macros/registry';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
// Module-level AutoSaveManager for idle-time based auto-saving
|
// Module-level AutoSaveManager for idle-time based auto-saving
|
||||||
@@ -103,12 +105,41 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render a macro synchronously for preview
|
||||||
|
const renderMacroSync = (name: string, params: Record<string, string>, postId?: string): string => {
|
||||||
|
const macro = getMacro(name);
|
||||||
|
if (!macro) {
|
||||||
|
return `<span class="macro-error">Unknown macro: ${name}</span>`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = macro.render(params, { postId, isPreview: true });
|
||||||
|
// If it returns a promise, show loading state (shouldn't happen for gallery)
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return `<div class="macro-loading">Loading ${name}...</div>`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
return `<span class="macro-error">Error rendering ${name}</span>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Simple markdown to HTML converter for preview
|
// Simple markdown to HTML converter for preview
|
||||||
const markdownToHtml = (markdown: string): string => {
|
const markdownToHtml = (markdown: string, postId?: string): string => {
|
||||||
return markdown
|
// First, render macros
|
||||||
// Escape HTML
|
const macros = parseMacros(markdown);
|
||||||
.replace(/</g, '<')
|
let result = markdown;
|
||||||
.replace(/>/g, '>')
|
|
||||||
|
// Replace macros from end to start to preserve positions
|
||||||
|
for (let i = macros.length - 1; i >= 0; i--) {
|
||||||
|
const macro = macros[i];
|
||||||
|
const rendered = renderMacroSync(macro.name, macro.params, postId);
|
||||||
|
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
// Escape HTML (but not our rendered macros - they're already safe)
|
||||||
|
// We need to be careful here - macro output contains HTML
|
||||||
|
// For safety, we skip escaping since we control the macro output
|
||||||
// Headers
|
// Headers
|
||||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||||
@@ -133,6 +164,69 @@ const markdownToHtml = (markdown: string): string => {
|
|||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate gallery elements in the preview with actual linked media
|
||||||
|
*/
|
||||||
|
const hydrateGalleries = async (
|
||||||
|
container: HTMLElement,
|
||||||
|
postId: string,
|
||||||
|
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
|
||||||
|
) => {
|
||||||
|
const galleries = container.querySelectorAll('.macro-gallery[data-post-id]');
|
||||||
|
|
||||||
|
for (const gallery of galleries) {
|
||||||
|
const galleryPostId = gallery.getAttribute('data-post-id');
|
||||||
|
if (!galleryPostId || galleryPostId !== postId) continue;
|
||||||
|
|
||||||
|
const galleryContainer = gallery.querySelector('.gallery-container');
|
||||||
|
if (!galleryContainer) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load linked media for this post
|
||||||
|
const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
|
||||||
|
|
||||||
|
if (!mediaData || mediaData.length === 0) {
|
||||||
|
galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to images only
|
||||||
|
const images = mediaData.filter(m => m.mimeType?.startsWith('image/'));
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build gallery grid (column count is handled via CSS class on parent)
|
||||||
|
galleryContainer.innerHTML = images.map((media, index) => `
|
||||||
|
<div class="gallery-item" data-index="${index}">
|
||||||
|
<img
|
||||||
|
src="bds-media://${media.id}"
|
||||||
|
alt="${media.alt || media.originalName}"
|
||||||
|
title="${media.originalName}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Set up lightbox click handlers
|
||||||
|
const items = galleryContainer.querySelectorAll('.gallery-item');
|
||||||
|
const imageData = images.map(m => ({
|
||||||
|
src: `bds-media://${m.id}`,
|
||||||
|
alt: m.alt || m.originalName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
item.addEventListener('click', () => onImageClick(index, imageData));
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to hydrate gallery:', error);
|
||||||
|
galleryContainer.innerHTML = '<div class="gallery-error">Failed to load gallery</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
post: PostData;
|
post: PostData;
|
||||||
}
|
}
|
||||||
@@ -159,7 +253,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
|
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
|
||||||
const editorRef = useRef<unknown>(null);
|
const editorRef = useRef<unknown>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isDirty = checkIsDirty(post.id);
|
const isDirty = checkIsDirty(post.id);
|
||||||
|
|
||||||
@@ -188,6 +284,34 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
|
|
||||||
// Extract images from resolved content for lightbox
|
// Extract images from resolved content for lightbox
|
||||||
const images = useMarkdownImages(resolvedContent);
|
const images = useMarkdownImages(resolvedContent);
|
||||||
|
|
||||||
|
// Combine regular images with gallery images for lightbox
|
||||||
|
const allImages = useMemo(() => {
|
||||||
|
// If gallery images are set, use those; otherwise use extracted images
|
||||||
|
return galleryImages.length > 0 ? galleryImages : images;
|
||||||
|
}, [images, galleryImages]);
|
||||||
|
|
||||||
|
// Hydrate galleries when in preview mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorMode !== 'preview' || !previewRef.current) return;
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is updated
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (previewRef.current) {
|
||||||
|
hydrateGalleries(
|
||||||
|
previewRef.current,
|
||||||
|
post.id,
|
||||||
|
(index, imgs) => {
|
||||||
|
setGalleryImages(imgs);
|
||||||
|
setLightboxIndex(index);
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [editorMode, post.id, resolvedContent]);
|
||||||
|
|
||||||
// Track latest values for auto-save on unmount/switch
|
// Track latest values for auto-save on unmount/switch
|
||||||
const pendingChangesRef = useRef<{
|
const pendingChangesRef = useRef<{
|
||||||
@@ -512,6 +636,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
postId={post.id}
|
postId={post.id}
|
||||||
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LinkedMediaPanel postId={post.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-body">
|
<div className="editor-body">
|
||||||
@@ -586,10 +712,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editorMode === 'preview' && (
|
{editorMode === 'preview' && (
|
||||||
<div className="editor-preview markdown-body">
|
<div className="editor-preview markdown-body" ref={previewRef}>
|
||||||
<div
|
<div
|
||||||
className="preview-content"
|
className="preview-content"
|
||||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent) }}
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent, post.id) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -597,10 +723,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
|
|
||||||
{/* Lightbox for viewing images in content */}
|
{/* Lightbox for viewing images in content */}
|
||||||
<Lightbox
|
<Lightbox
|
||||||
images={images}
|
images={allImages}
|
||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
isOpen={lightboxOpen}
|
isOpen={lightboxOpen}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,12 +748,71 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||||
const { media, updateMedia, showErrorModal } = useAppStore();
|
const { media, posts, updateMedia, showErrorModal, openTab } = useAppStore();
|
||||||
const item = media.find(m => m.id === mediaId);
|
const item = media.find(m => m.id === mediaId);
|
||||||
|
|
||||||
const [alt, setAlt] = useState(item?.alt || '');
|
const [alt, setAlt] = useState(item?.alt || '');
|
||||||
const [caption, setCaption] = useState(item?.caption || '');
|
const [caption, setCaption] = useState(item?.caption || '');
|
||||||
const [tags, setTags] = useState(item?.tags.join(', ') || '');
|
const [tags, setTags] = useState(item?.tags.join(', ') || '');
|
||||||
|
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
|
||||||
|
const [showPostPicker, setShowPostPicker] = useState(false);
|
||||||
|
|
||||||
|
// Load linked posts for this media
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLinkedPosts = async () => {
|
||||||
|
if (!mediaId) return;
|
||||||
|
try {
|
||||||
|
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||||||
|
if (links) {
|
||||||
|
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load linked posts:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadLinkedPosts();
|
||||||
|
}, [mediaId]);
|
||||||
|
|
||||||
|
// Get post titles for display
|
||||||
|
const getPostTitle = (postId: string): string => {
|
||||||
|
const post = posts.find(p => p.id === postId);
|
||||||
|
return post?.title || 'Untitled';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle linking to a new post
|
||||||
|
const handleLinkToPost = async (postId: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||||||
|
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
|
||||||
|
setShowPostPicker(false);
|
||||||
|
showToast.success('Linked to post');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to link to post:', error);
|
||||||
|
showToast.error('Failed to link to post');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle unlinking from a post
|
||||||
|
const handleUnlinkFromPost = async (postId: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.postMedia.unlink(postId, mediaId);
|
||||||
|
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
|
||||||
|
showToast.success('Unlinked from post');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unlink from post:', error);
|
||||||
|
showToast.error('Failed to unlink from post');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle click on a post to navigate to it
|
||||||
|
const handlePostClick = (postId: string) => {
|
||||||
|
openTab({ type: 'post', id: postId, isTransient: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unlinked posts for picker
|
||||||
|
const unlinkedPosts = posts.filter(
|
||||||
|
p => !linkedPosts.find(l => l.postId === p.id)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -768,6 +953,70 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
placeholder="tag1, tag2, tag3"
|
placeholder="tag1, tag2, tag3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked Posts Section */}
|
||||||
|
<div className="editor-field linked-posts-section">
|
||||||
|
<label>
|
||||||
|
Linked Posts
|
||||||
|
<button
|
||||||
|
className="add-link-btn"
|
||||||
|
onClick={() => setShowPostPicker(!showPostPicker)}
|
||||||
|
title="Link to a post"
|
||||||
|
>
|
||||||
|
+ Link
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{showPostPicker && (
|
||||||
|
<div className="post-picker">
|
||||||
|
{unlinkedPosts.length === 0 ? (
|
||||||
|
<div className="no-posts">No posts available to link</div>
|
||||||
|
) : (
|
||||||
|
<div className="post-picker-list">
|
||||||
|
{unlinkedPosts.slice(0, 10).map(post => (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
className="post-picker-item"
|
||||||
|
onClick={() => handleLinkToPost(post.id)}
|
||||||
|
>
|
||||||
|
{post.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{unlinkedPosts.length > 10 && (
|
||||||
|
<div className="post-picker-more">
|
||||||
|
+{unlinkedPosts.length - 10} more posts
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedPosts.length === 0 ? (
|
||||||
|
<div className="no-linked-posts">Not linked to any posts</div>
|
||||||
|
) : (
|
||||||
|
<div className="linked-posts-list">
|
||||||
|
{linkedPosts.map(({ postId }) => (
|
||||||
|
<div key={postId} className="linked-post-item">
|
||||||
|
<span
|
||||||
|
className="linked-post-title"
|
||||||
|
onClick={() => handlePostClick(postId)}
|
||||||
|
title="Open post"
|
||||||
|
>
|
||||||
|
📄 {getPostTitle(postId)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="unlink-btn"
|
||||||
|
onClick={() => handleUnlinkFromPost(postId)}
|
||||||
|
title="Unlink from post"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
285
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css
Normal file
285
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* LinkedMediaPanel Styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.linked-media-panel {
|
||||||
|
background: var(--color-bg-secondary, #252526);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel.collapsed {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel.collapsed:hover {
|
||||||
|
background: var(--color-bg-hover, #2a2d2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-primary, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-action:hover {
|
||||||
|
background: var(--color-bg-hover, #3c3c3c);
|
||||||
|
color: var(--color-text-primary, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon,
|
||||||
|
.collapse-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media Grid */
|
||||||
|
.linked-media-panel .media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-bg-tertiary, #1e1e1e);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item.drag-over {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent, #007acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-thumbnail {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg-secondary, #252526);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-thumbnail .media-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .unlink-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-item:hover .unlink-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .unlink-btn:hover {
|
||||||
|
color: var(--color-error, #f14c4c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-media-panel .media-order {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state button {
|
||||||
|
background: var(--color-accent, #007acc);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state button:hover {
|
||||||
|
background: var(--color-accent-hover, #0587d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media Picker */
|
||||||
|
.media-picker {
|
||||||
|
border-top: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
background: var(--color-bg-tertiary, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border, #3c3c3c);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-header button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-header button:hover {
|
||||||
|
color: var(--color-text-primary, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-item:hover {
|
||||||
|
background: var(--color-bg-hover, #2a2d2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-item img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-item .media-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
background: var(--color-bg-secondary, #252526);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-picker-item .media-name {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-media {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--color-text-secondary, #8b8b8b);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
275
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx
Normal file
275
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* LinkedMediaPanel Component
|
||||||
|
*
|
||||||
|
* Displays media files linked to a post with the ability to:
|
||||||
|
* - View linked media in a grid/list
|
||||||
|
* - Import new media files (automatically linked to post)
|
||||||
|
* - Unlink media files from post
|
||||||
|
* - Reorder media files via drag and drop
|
||||||
|
* - Link existing media to the post
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAppStore, MediaData } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import './LinkedMediaPanel.css';
|
||||||
|
|
||||||
|
interface LinkedMediaPanelProps {
|
||||||
|
postId: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||||
|
postId,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}) => {
|
||||||
|
const [linkedMedia, setLinkedMedia] = useState<MediaData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
const [showMediaPicker, setShowMediaPicker] = useState(false);
|
||||||
|
const { media: allMedia } = useAppStore();
|
||||||
|
|
||||||
|
// Load linked media for this post
|
||||||
|
const loadLinkedMedia = useCallback(async () => {
|
||||||
|
if (!postId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
|
||||||
|
setLinkedMedia(mediaData || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load linked media:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLinkedMedia();
|
||||||
|
}, [loadLinkedMedia]);
|
||||||
|
|
||||||
|
// Handle importing new media with auto-link
|
||||||
|
const handleImportMedia = async () => {
|
||||||
|
try {
|
||||||
|
// Get imported media using the standard dialog
|
||||||
|
const imported = await window.electronAPI?.media.importDialog();
|
||||||
|
if (!imported || imported.length === 0) return;
|
||||||
|
|
||||||
|
// Link each imported media to this post
|
||||||
|
for (const media of imported) {
|
||||||
|
await window.electronAPI?.postMedia.link(postId, media.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast.success(`Imported and linked ${imported.length} file(s)`);
|
||||||
|
|
||||||
|
// Refresh the linked media list
|
||||||
|
loadLinkedMedia();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import media:', error);
|
||||||
|
showToast.error('Failed to import media');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle unlinking media
|
||||||
|
const handleUnlink = async (mediaId: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.postMedia.unlink(postId, mediaId);
|
||||||
|
showToast.success('Media unlinked from post');
|
||||||
|
loadLinkedMedia();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unlink media:', error);
|
||||||
|
showToast.error('Failed to unlink media');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle linking existing media
|
||||||
|
const handleLinkExisting = async (mediaId: string) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||||||
|
showToast.success('Media linked to post');
|
||||||
|
setShowMediaPicker(false);
|
||||||
|
loadLinkedMedia();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to link media:', error);
|
||||||
|
showToast.error('Failed to link media');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop reordering
|
||||||
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
|
e.dataTransfer.setData('text/plain', index.toString());
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent, targetIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverIndex(null);
|
||||||
|
|
||||||
|
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||||
|
if (sourceIndex === targetIndex) return;
|
||||||
|
|
||||||
|
// Build new order
|
||||||
|
const newOrder = [...linkedMedia];
|
||||||
|
const [removed] = newOrder.splice(sourceIndex, 1);
|
||||||
|
newOrder.splice(targetIndex, 0, removed);
|
||||||
|
|
||||||
|
const mediaIds = newOrder.map(m => m.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.postMedia.reorder(postId, mediaIds);
|
||||||
|
setLinkedMedia(newOrder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder media:', error);
|
||||||
|
loadLinkedMedia(); // Revert on failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle click on media item to open media viewer
|
||||||
|
const handleMediaClick = (mediaId: string) => {
|
||||||
|
useAppStore.getState().openTab({ type: 'media', id: mediaId, isTransient: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get thumbnail URL for a media item
|
||||||
|
const getThumbnailUrl = (media: MediaData): string | null => {
|
||||||
|
if (media.mimeType?.startsWith('image/')) {
|
||||||
|
return `bds-media://${media.id}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unlinked media (for picker)
|
||||||
|
const unlinkedMedia = allMedia.filter(
|
||||||
|
m => !linkedMedia.find(l => l.id === m.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="linked-media-panel collapsed" onClick={onToggleCollapse}>
|
||||||
|
<div className="panel-header">
|
||||||
|
<span className="panel-title">
|
||||||
|
📷 Media ({linkedMedia.length})
|
||||||
|
</span>
|
||||||
|
<span className="expand-icon">▶</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="linked-media-panel">
|
||||||
|
<div className="panel-header" onClick={onToggleCollapse}>
|
||||||
|
<span className="panel-title">📷 Linked Media</span>
|
||||||
|
<div className="panel-actions">
|
||||||
|
<button
|
||||||
|
className="panel-action"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleImportMedia(); }}
|
||||||
|
title="Import and link media"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="panel-action"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowMediaPicker(!showMediaPicker); }}
|
||||||
|
title="Link existing media"
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
{onToggleCollapse && <span className="collapse-icon">▼</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMediaPicker && (
|
||||||
|
<div className="media-picker">
|
||||||
|
<div className="media-picker-header">
|
||||||
|
<span>Select media to link</span>
|
||||||
|
<button onClick={() => setShowMediaPicker(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="media-picker-grid">
|
||||||
|
{unlinkedMedia.length === 0 ? (
|
||||||
|
<div className="no-media">No unlinked media available</div>
|
||||||
|
) : (
|
||||||
|
unlinkedMedia.map(media => (
|
||||||
|
<div
|
||||||
|
key={media.id}
|
||||||
|
className="media-picker-item"
|
||||||
|
onClick={() => handleLinkExisting(media.id)}
|
||||||
|
title={media.originalName}
|
||||||
|
>
|
||||||
|
{media.mimeType?.startsWith('image/') ? (
|
||||||
|
<img src={`bds-media://${media.id}`} alt={media.originalName} />
|
||||||
|
) : (
|
||||||
|
<div className="media-icon">📄</div>
|
||||||
|
)}
|
||||||
|
<span className="media-name">{media.originalName}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="panel-content">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
) : linkedMedia.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No media linked to this post</p>
|
||||||
|
<button onClick={handleImportMedia}>Import Media</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="media-grid">
|
||||||
|
{linkedMedia.map((media, index) => (
|
||||||
|
<div
|
||||||
|
key={media.id}
|
||||||
|
className={`media-item ${dragOverIndex === index ? 'drag-over' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="media-thumbnail"
|
||||||
|
onClick={() => handleMediaClick(media.id)}
|
||||||
|
>
|
||||||
|
{getThumbnailUrl(media) ? (
|
||||||
|
<img src={getThumbnailUrl(media)!} alt={media.originalName} />
|
||||||
|
) : (
|
||||||
|
<div className="media-icon">📄</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="media-info">
|
||||||
|
<span className="media-name" title={media.originalName}>
|
||||||
|
{media.originalName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="unlink-btn"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleUnlink(media.id); }}
|
||||||
|
title="Unlink from post"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="media-order">{index + 1}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkedMediaPanel;
|
||||||
2
src/renderer/components/LinkedMediaPanel/index.ts
Normal file
2
src/renderer/components/LinkedMediaPanel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||||
|
export { default } from './LinkedMediaPanel';
|
||||||
@@ -15,5 +15,6 @@ export { SettingsView } from './SettingsView';
|
|||||||
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||||
export { TagInput } from './TagInput';
|
export { TagInput } from './TagInput';
|
||||||
export { PostLinks } from './PostLinks';
|
export { PostLinks } from './PostLinks';
|
||||||
|
export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||||
export { ChatPanel } from './ChatPanel';
|
export { ChatPanel } from './ChatPanel';
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Gallery Macro
|
* Gallery Macro
|
||||||
*
|
*
|
||||||
* Renders an image gallery from a linked media file or folder.
|
* Renders an image gallery from linked media files for a post.
|
||||||
|
* Uses the post-media linking system to display media attached to the current post.
|
||||||
|
* Images are clickable to open in a lightbox.
|
||||||
*
|
*
|
||||||
* Usage: [[gallery link="media/photos" columns="3" caption="My Photos"]]
|
* Usage:
|
||||||
|
* [[gallery]] - Shows all linked media for current post
|
||||||
|
* [[gallery columns="4"]] - Custom column count
|
||||||
|
* [[gallery caption="My Photos"]] - With caption
|
||||||
*
|
*
|
||||||
* Parameters:
|
* Parameters:
|
||||||
* - link (required): Path to media file or folder
|
|
||||||
* - columns: Number of columns (default: 3)
|
* - columns: Number of columns (default: 3)
|
||||||
* - caption: Gallery caption
|
* - caption: Gallery caption
|
||||||
*/
|
*/
|
||||||
@@ -16,12 +20,9 @@ import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'
|
|||||||
|
|
||||||
const galleryMacro: MacroDefinition = {
|
const galleryMacro: MacroDefinition = {
|
||||||
name: 'gallery',
|
name: 'gallery',
|
||||||
description: 'Renders an image gallery from linked media',
|
description: 'Renders an image gallery from linked media files with lightbox support',
|
||||||
|
|
||||||
validate(params: MacroParams): string | undefined {
|
validate(params: MacroParams): string | undefined {
|
||||||
if (!params.link) {
|
|
||||||
return 'Gallery macro requires a "link" parameter';
|
|
||||||
}
|
|
||||||
if (params.columns) {
|
if (params.columns) {
|
||||||
const cols = parseInt(params.columns, 10);
|
const cols = parseInt(params.columns, 10);
|
||||||
if (isNaN(cols) || cols < 1 || cols > 6) {
|
if (isNaN(cols) || cols < 1 || cols > 6) {
|
||||||
@@ -32,41 +33,37 @@ const galleryMacro: MacroDefinition = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editorPreview(params: MacroParams): string {
|
editorPreview(params: MacroParams): string {
|
||||||
const link = params.link || '?';
|
const cols = params.columns || '3';
|
||||||
return `📷 Gallery: ${link}`;
|
return `📷 Gallery (${cols} cols)`;
|
||||||
},
|
},
|
||||||
|
|
||||||
render(params: MacroParams, context: MacroRenderContext): string {
|
render(params: MacroParams, context: MacroRenderContext): string {
|
||||||
const { link, columns = '3', caption } = params;
|
const { columns = '3', caption } = params;
|
||||||
const colCount = parseInt(columns, 10) || 3;
|
const colCount = parseInt(columns, 10) || 3;
|
||||||
|
|
||||||
// Build the gallery HTML
|
// Build the gallery HTML with lightbox support
|
||||||
const classes = ['macro-gallery', `gallery-cols-${colCount}`];
|
const classes = ['macro-gallery', `gallery-cols-${colCount}`];
|
||||||
if (context.isPreview) {
|
|
||||||
classes.push('gallery-preview');
|
// Data attributes for hydration - JS will load linked media and populate
|
||||||
|
const dataAttrs = [
|
||||||
|
`data-columns="${colCount}"`,
|
||||||
|
`data-lightbox="true"`,
|
||||||
|
];
|
||||||
|
if (context.postId) {
|
||||||
|
dataAttrs.push(`data-post-id="${context.postId}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = `<div class="${classes.join(' ')}" data-link="${link}">`;
|
let html = `<div class="${classes.join(' ')}" ${dataAttrs.join(' ')}>`;
|
||||||
|
|
||||||
// In preview mode, show a placeholder
|
// Gallery container that will be populated by hydration script
|
||||||
// In production, this would load actual images
|
// The hydration script uses: window.electronAPI.postMedia.getMediaDataForPost(postId)
|
||||||
if (context.isPreview) {
|
// and renders images with bds-media:// protocol
|
||||||
html += `<div class="gallery-placeholder">`;
|
html += `<div class="gallery-container gallery-lightbox">`;
|
||||||
html += `<span class="gallery-icon">🖼️</span>`;
|
html += `<div class="gallery-loading">Loading gallery...</div>`;
|
||||||
html += `<span class="gallery-info">Gallery: ${link}</span>`;
|
html += `</div>`;
|
||||||
if (caption) {
|
|
||||||
html += `<span class="gallery-caption">${caption}</span>`;
|
if (caption) {
|
||||||
}
|
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
|
||||||
html += `</div>`;
|
|
||||||
} else {
|
|
||||||
// Production render would load images here
|
|
||||||
// For now, create a placeholder that frontend JS can hydrate
|
|
||||||
html += `<div class="gallery-container" data-columns="${colCount}">`;
|
|
||||||
html += `<!-- Gallery images loaded dynamically from: ${link} -->`;
|
|
||||||
html += `</div>`;
|
|
||||||
if (caption) {
|
|
||||||
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|||||||
21
src/renderer/types/electron.d.ts
vendored
21
src/renderer/types/electron.d.ts
vendored
@@ -172,6 +172,16 @@ export interface SyncTagsResult {
|
|||||||
added: string[];
|
added: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-Media Link types
|
||||||
|
export interface MediaLinkData {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
postId: string;
|
||||||
|
mediaId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Chat/AI types
|
// Chat/AI types
|
||||||
export interface ChatConversation {
|
export interface ChatConversation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -282,6 +292,17 @@ export interface ElectronAPI {
|
|||||||
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
|
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
|
||||||
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
|
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
|
||||||
};
|
};
|
||||||
|
postMedia: {
|
||||||
|
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
|
||||||
|
unlink: (postId: string, mediaId: string) => Promise<void>;
|
||||||
|
getForPost: (postId: string) => Promise<MediaLinkData[]>;
|
||||||
|
getForMedia: (mediaId: string) => Promise<MediaLinkData[]>;
|
||||||
|
getMediaDataForPost: (postId: string) => Promise<MediaData[]>;
|
||||||
|
reorder: (postId: string, mediaIds: string[]) => Promise<void>;
|
||||||
|
isLinked: (postId: string, mediaId: string) => Promise<boolean>;
|
||||||
|
import: (postId: string, filePath: string) => Promise<MediaLinkData>;
|
||||||
|
rebuild: () => Promise<void>;
|
||||||
|
};
|
||||||
sync: {
|
sync: {
|
||||||
configure: (config: SyncConfig) => Promise<void>;
|
configure: (config: SyncConfig) => Promise<void>;
|
||||||
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
|
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
|
||||||
|
|||||||
375
tests/engine/PostMediaEngine.test.ts
Normal file
375
tests/engine/PostMediaEngine.test.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* PostMediaEngine Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the REAL PostMediaEngine class with mocked dependencies.
|
||||||
|
* Following TDD best practices: mock external dependencies, test real implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { resetMockCounters, createMockMedia } from '../utils/factories';
|
||||||
|
|
||||||
|
// Mock electron BEFORE importing engine
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: vi.fn((name: string) => {
|
||||||
|
const paths: Record<string, string> = {
|
||||||
|
userData: '/mock/userData',
|
||||||
|
appData: '/mock/appData',
|
||||||
|
temp: '/mock/temp',
|
||||||
|
};
|
||||||
|
return paths[name] || '/mock/unknown';
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create mock data stores
|
||||||
|
const mockPostMedia = new Map<string, any>();
|
||||||
|
|
||||||
|
// MediaEngine mock functions - defined at module level
|
||||||
|
const mockGetMedia = vi.fn();
|
||||||
|
const mockUpdateMedia = vi.fn();
|
||||||
|
const mockGetAllMedia = vi.fn();
|
||||||
|
const mockImportMedia = vi.fn();
|
||||||
|
|
||||||
|
// Mock MediaEngine
|
||||||
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||||
|
getMediaEngine: vi.fn(() => ({
|
||||||
|
getMedia: mockGetMedia,
|
||||||
|
updateMedia: mockUpdateMedia,
|
||||||
|
getAllMedia: mockGetAllMedia,
|
||||||
|
importMedia: mockImportMedia,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create chainable mock for Drizzle ORM
|
||||||
|
// Drizzle query chains are "thenable" - they resolve to results when awaited
|
||||||
|
function createSelectChain(mockData: any[] = []) {
|
||||||
|
const chain: any = {
|
||||||
|
from: vi.fn().mockImplementation(() => chain),
|
||||||
|
where: vi.fn().mockImplementation(() => chain),
|
||||||
|
orderBy: vi.fn().mockImplementation(() => chain),
|
||||||
|
limit: vi.fn().mockImplementation(() => chain),
|
||||||
|
offset: vi.fn().mockImplementation(() => chain),
|
||||||
|
all: vi.fn().mockResolvedValue(mockData),
|
||||||
|
get: vi.fn().mockResolvedValue(mockData[0] || undefined),
|
||||||
|
// Make chain "thenable" so it can be awaited
|
||||||
|
then: (resolve: any, reject: any) => Promise.resolve(mockData).then(resolve, reject),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track database operations
|
||||||
|
let insertedValues: any[] = [];
|
||||||
|
let updateCalls: any[] = [];
|
||||||
|
let deleteCalled = false;
|
||||||
|
let selectMockData: any[] = [];
|
||||||
|
|
||||||
|
function createDrizzleMock() {
|
||||||
|
return {
|
||||||
|
select: vi.fn(() => createSelectChain(selectMockData)),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn((data: any) => {
|
||||||
|
if (data && data.id) {
|
||||||
|
mockPostMedia.set(data.id, data);
|
||||||
|
insertedValues.push(data);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
set: vi.fn((data: any) => ({
|
||||||
|
where: vi.fn(() => {
|
||||||
|
updateCalls.push(data);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
delete: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => {
|
||||||
|
deleteCalled = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLocalDb = createDrizzleMock();
|
||||||
|
|
||||||
|
// Mock the database module
|
||||||
|
vi.mock('../../src/main/database', () => ({
|
||||||
|
getDatabase: vi.fn(() => ({
|
||||||
|
getLocal: vi.fn(() => mockLocalDb),
|
||||||
|
getLocalClient: vi.fn(() => ({
|
||||||
|
execute: vi.fn().mockResolvedValue({ rows: [] }),
|
||||||
|
})),
|
||||||
|
getRemote: vi.fn(() => null),
|
||||||
|
getDataPaths: vi.fn(() => ({
|
||||||
|
database: '/mock/userData/bds.db',
|
||||||
|
posts: '/mock/userData/posts',
|
||||||
|
media: '/mock/userData/media',
|
||||||
|
})),
|
||||||
|
initializeLocal: vi.fn(),
|
||||||
|
initializeRemote: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
import { PostMediaEngine } from '../../src/main/engine/PostMediaEngine';
|
||||||
|
|
||||||
|
describe('PostMediaEngine', () => {
|
||||||
|
let engine: PostMediaEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockPostMedia.clear();
|
||||||
|
insertedValues = [];
|
||||||
|
updateCalls = [];
|
||||||
|
deleteCalled = false;
|
||||||
|
selectMockData = [];
|
||||||
|
resetMockCounters();
|
||||||
|
|
||||||
|
// Reset MediaEngine mocks
|
||||||
|
mockGetMedia.mockReset();
|
||||||
|
mockUpdateMedia.mockReset();
|
||||||
|
mockGetAllMedia.mockReset();
|
||||||
|
mockImportMedia.mockReset();
|
||||||
|
|
||||||
|
// Default implementations
|
||||||
|
mockGetMedia.mockResolvedValue(null);
|
||||||
|
mockUpdateMedia.mockResolvedValue(undefined);
|
||||||
|
mockGetAllMedia.mockResolvedValue([]);
|
||||||
|
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
|
||||||
|
|
||||||
|
engine = new PostMediaEngine();
|
||||||
|
engine.setProjectContext('test-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Project Context', () => {
|
||||||
|
it('should set project context', () => {
|
||||||
|
engine.setProjectContext('my-blog');
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing project context multiple times', () => {
|
||||||
|
engine.setProjectContext('blog-1');
|
||||||
|
engine.setProjectContext('blog-2');
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('linkMediaToPost', () => {
|
||||||
|
it('should create a new link between media and post', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
|
||||||
|
// Setup mock media
|
||||||
|
const mockMediaData = createMockMedia({ id: mediaId, linkedPostIds: [] });
|
||||||
|
mockGetMedia.mockResolvedValue(mockMediaData);
|
||||||
|
|
||||||
|
const result = await engine.linkMediaToPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.postId).toBe(postId);
|
||||||
|
expect(result.mediaId).toBe(mediaId);
|
||||||
|
expect(result.sortOrder).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update media sidecar with linkedPostIds', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
const mockMediaData = createMockMedia({ id: mediaId, linkedPostIds: [] });
|
||||||
|
mockGetMedia.mockResolvedValue(mockMediaData);
|
||||||
|
|
||||||
|
await engine.linkMediaToPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(mockUpdateMedia).toHaveBeenCalledWith(
|
||||||
|
mediaId,
|
||||||
|
expect.objectContaining({
|
||||||
|
linkedPostIds: expect.arrayContaining([postId]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit mediaLinked event', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId }));
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
engine.on('mediaLinked', handler);
|
||||||
|
|
||||||
|
await engine.linkMediaToPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ postId, mediaId })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlinkMediaFromPost', () => {
|
||||||
|
it('should remove link between media and post', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
|
||||||
|
mockGetMedia.mockResolvedValue(
|
||||||
|
createMockMedia({ id: mediaId, linkedPostIds: [postId] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await engine.unlinkMediaFromPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(deleteCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update media sidecar to remove postId', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
mockGetMedia.mockResolvedValue(
|
||||||
|
createMockMedia({ id: mediaId, linkedPostIds: [postId, 'other-post'] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await engine.unlinkMediaFromPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(mockUpdateMedia).toHaveBeenCalledWith(
|
||||||
|
mediaId,
|
||||||
|
expect.objectContaining({
|
||||||
|
linkedPostIds: ['other-post'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit mediaUnlinked event', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
mockGetMedia.mockResolvedValue(
|
||||||
|
createMockMedia({ id: mediaId, linkedPostIds: [postId] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
engine.on('mediaUnlinked', handler);
|
||||||
|
|
||||||
|
await engine.unlinkMediaFromPost(postId, mediaId);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ postId, mediaId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLinkedMediaForPost', () => {
|
||||||
|
it('should return all media linked to a post in sort order', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
|
||||||
|
// Mock the database to return sorted links
|
||||||
|
selectMockData = [
|
||||||
|
{ id: 'link-2', projectId: 'test-project', postId, mediaId: 'media-2', sortOrder: 0, createdAt: new Date() },
|
||||||
|
{ id: 'link-1', projectId: 'test-project', postId, mediaId: 'media-1', sortOrder: 1, createdAt: new Date() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await engine.getLinkedMediaForPost(postId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].mediaId).toBe('media-2');
|
||||||
|
expect(result[1].mediaId).toBe('media-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if no media linked', async () => {
|
||||||
|
selectMockData = [];
|
||||||
|
|
||||||
|
const result = await engine.getLinkedMediaForPost('post-with-no-media');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLinkedPostsForMedia', () => {
|
||||||
|
it('should return all posts linked to a media file', async () => {
|
||||||
|
const mediaId = 'media-1';
|
||||||
|
|
||||||
|
selectMockData = [
|
||||||
|
{ id: 'link-1', projectId: 'test-project', postId: 'post-1', mediaId, sortOrder: 0, createdAt: new Date() },
|
||||||
|
{ id: 'link-2', projectId: 'test-project', postId: 'post-2', mediaId, sortOrder: 0, createdAt: new Date() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await engine.getLinkedPostsForMedia(mediaId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map(l => l.postId)).toContain('post-1');
|
||||||
|
expect(result.map(l => l.postId)).toContain('post-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reorderMediaForPost', () => {
|
||||||
|
it('should update sortOrder for all media in new order', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const newOrder = ['media-2', 'media-3', 'media-1'];
|
||||||
|
|
||||||
|
await engine.reorderMediaForPost(postId, newOrder);
|
||||||
|
|
||||||
|
expect(updateCalls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit mediaReordered event', async () => {
|
||||||
|
const postId = 'post-1';
|
||||||
|
const newOrder = ['media-2', 'media-1'];
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
engine.on('mediaReordered', handler);
|
||||||
|
|
||||||
|
await engine.reorderMediaForPost(postId, newOrder);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ postId, mediaIds: newOrder });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rebuildFromSidecars', () => {
|
||||||
|
it('should rebuild junction table from media sidecar linkedPostIds', async () => {
|
||||||
|
const media1 = createMockMedia({
|
||||||
|
id: 'media-1',
|
||||||
|
linkedPostIds: ['post-1', 'post-2']
|
||||||
|
});
|
||||||
|
const media2 = createMockMedia({
|
||||||
|
id: 'media-2',
|
||||||
|
linkedPostIds: ['post-1']
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGetAllMedia.mockResolvedValue([media1, media2]);
|
||||||
|
|
||||||
|
await engine.rebuildFromSidecars();
|
||||||
|
|
||||||
|
// Should have deleted existing links first
|
||||||
|
expect(deleteCalled).toBe(true);
|
||||||
|
// Should have created 3 links total (2 for media-1 + 1 for media-2)
|
||||||
|
expect(insertedValues).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit rebuilt event when complete', async () => {
|
||||||
|
mockGetAllMedia.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
engine.on('rebuilt', handler);
|
||||||
|
|
||||||
|
await engine.rebuildFromSidecars();
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMediaLinkedToPost', () => {
|
||||||
|
it('should return true when media is linked to post', async () => {
|
||||||
|
selectMockData = [
|
||||||
|
{ id: 'link-1', postId: 'post-1', mediaId: 'media-1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await engine.isMediaLinkedToPost('post-1', 'media-1');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when media is not linked to post', async () => {
|
||||||
|
selectMockData = [];
|
||||||
|
|
||||||
|
const result = await engine.isMediaLinkedToPost('post-1', 'media-1');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user