feat: gallery macro
This commit is contained in:
@@ -182,6 +182,15 @@ export class DatabaseConnection {
|
||||
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_status ON posts(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_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_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 TABLE IF NOT EXISTS tags (
|
||||
|
||||
@@ -96,6 +96,19 @@ export const postLinks = sqliteTable('post_links', {
|
||||
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
|
||||
export const tags = sqliteTable('tags', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -143,6 +156,8 @@ export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
export type PostLink = typeof postLinks.$inferSelect;
|
||||
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 NewTag = typeof tags.$inferInsert;
|
||||
export type ChatConversation = typeof chatConversations.$inferSelect;
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface MediaData {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags: string[];
|
||||
linkedPostIds?: string[]; // Posts this media is linked to
|
||||
}
|
||||
|
||||
export interface MediaMetadata {
|
||||
@@ -45,6 +46,7 @@ export interface MediaMetadata {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar)
|
||||
}
|
||||
|
||||
export class MediaEngine extends EventEmitter {
|
||||
@@ -227,6 +229,7 @@ export class MediaEngine extends EventEmitter {
|
||||
createdAt: mediaData.createdAt.toISOString(),
|
||||
updatedAt: mediaData.updatedAt.toISOString(),
|
||||
tags: mediaData.tags,
|
||||
linkedPostIds: mediaData.linkedPostIds,
|
||||
};
|
||||
|
||||
// Write YAML-like format consistent with posts
|
||||
@@ -246,6 +249,9 @@ export class MediaEngine extends EventEmitter {
|
||||
lines.push(`createdAt: ${metadata.createdAt}`);
|
||||
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
||||
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('---');
|
||||
|
||||
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
||||
@@ -267,6 +273,7 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
const metadata: Partial<MediaMetadata> = {
|
||||
tags: [],
|
||||
linkedPostIds: [],
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -316,14 +323,24 @@ export class MediaEngine extends EventEmitter {
|
||||
break;
|
||||
case 'tags':
|
||||
// Parse array format: ["tag1", "tag2"]
|
||||
const match = value.match(/\[(.*)\]/);
|
||||
if (match) {
|
||||
metadata.tags = match[1]
|
||||
const tagsMatch = value.match(/\[(.*)\]/);
|
||||
if (tagsMatch) {
|
||||
metadata.tags = tagsMatch[1]
|
||||
.split(',')
|
||||
.map(t => t.trim().replace(/"/g, ''))
|
||||
.filter(t => t.length > 0);
|
||||
}
|
||||
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 { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine';
|
||||
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 { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
||||
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 { getMetaEngine } from '../engine/MetaEngine';
|
||||
import { getTagEngine } from '../engine/TagEngine';
|
||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
import { media } from '../database/schema';
|
||||
@@ -77,6 +78,8 @@ export function registerIpcHandlers(): void {
|
||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||
metaEngine.setProjectContext(project.id);
|
||||
tagEngine.setProjectContext(project.id);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
// Sync meta on startup
|
||||
await metaEngine.syncOnStartup();
|
||||
@@ -101,6 +104,8 @@ export function registerIpcHandlers(): void {
|
||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
||||
metaEngine.setProjectContext(project.id);
|
||||
tagEngine.setProjectContext(project.id);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
// Sync meta on project switch
|
||||
await metaEngine.syncOnStartup();
|
||||
@@ -668,6 +673,53 @@ export function registerIpcHandlers(): void {
|
||||
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 ============
|
||||
|
||||
// Forward engine events to renderer
|
||||
@@ -677,6 +729,7 @@ export function registerIpcHandlers(): void {
|
||||
const projectEngine = getProjectEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
|
||||
const forwardEvent = (eventName: string) => {
|
||||
return (...args: unknown[]) => {
|
||||
@@ -713,6 +766,11 @@ export function registerIpcHandlers(): void {
|
||||
tagEngine.on('tagsMerged', forwardEvent('tags:merged'));
|
||||
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('syncCompleted', forwardEvent('sync:completed'));
|
||||
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
||||
|
||||
@@ -59,6 +59,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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: {
|
||||
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
|
||||
|
||||
@@ -606,3 +606,187 @@
|
||||
font-size: 11px;
|
||||
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 { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
import { PostLinks } from '../PostLinks';
|
||||
import { LinkedMediaPanel } from '../LinkedMediaPanel';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { TagInput } from '../TagInput';
|
||||
import { ChatPanel } from '../ChatPanel';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import { parseMacros, getMacro } from '../../macros/registry';
|
||||
import './Editor.css';
|
||||
|
||||
// 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
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
return markdown
|
||||
// Escape HTML
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
const markdownToHtml = (markdown: string, postId?: string): string => {
|
||||
// First, render macros
|
||||
const macros = parseMacros(markdown);
|
||||
let result = markdown;
|
||||
|
||||
// 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
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
@@ -133,6 +164,69 @@ const markdownToHtml = (markdown: string): string => {
|
||||
.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 {
|
||||
post: PostData;
|
||||
}
|
||||
@@ -159,7 +253,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDirty = checkIsDirty(post.id);
|
||||
|
||||
@@ -189,6 +285,34 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
// Extract images from resolved content for lightbox
|
||||
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
|
||||
const pendingChangesRef = useRef<{
|
||||
title: string;
|
||||
@@ -512,6 +636,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
postId={post.id}
|
||||
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||||
/>
|
||||
|
||||
<LinkedMediaPanel postId={post.id} />
|
||||
</div>
|
||||
|
||||
<div className="editor-body">
|
||||
@@ -586,10 +712,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
)}
|
||||
|
||||
{editorMode === 'preview' && (
|
||||
<div className="editor-preview markdown-body">
|
||||
<div className="editor-preview markdown-body" ref={previewRef}>
|
||||
<div
|
||||
className="preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent) }}
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent, post.id) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -597,10 +723,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
|
||||
{/* Lightbox for viewing images in content */}
|
||||
<Lightbox
|
||||
images={images}
|
||||
images={allImages}
|
||||
initialIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -622,12 +748,71 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
};
|
||||
|
||||
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 [alt, setAlt] = useState(item?.alt || '');
|
||||
const [caption, setCaption] = useState(item?.caption || '');
|
||||
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(() => {
|
||||
if (item) {
|
||||
@@ -768,6 +953,70 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
</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>
|
||||
|
||||
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 { TagInput } from './TagInput';
|
||||
export { PostLinks } from './PostLinks';
|
||||
export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* 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:
|
||||
* - link (required): Path to media file or folder
|
||||
* - columns: Number of columns (default: 3)
|
||||
* - caption: Gallery caption
|
||||
*/
|
||||
@@ -16,12 +20,9 @@ import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'
|
||||
|
||||
const galleryMacro: MacroDefinition = {
|
||||
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 {
|
||||
if (!params.link) {
|
||||
return 'Gallery macro requires a "link" parameter';
|
||||
}
|
||||
if (params.columns) {
|
||||
const cols = parseInt(params.columns, 10);
|
||||
if (isNaN(cols) || cols < 1 || cols > 6) {
|
||||
@@ -32,42 +33,38 @@ const galleryMacro: MacroDefinition = {
|
||||
},
|
||||
|
||||
editorPreview(params: MacroParams): string {
|
||||
const link = params.link || '?';
|
||||
return `📷 Gallery: ${link}`;
|
||||
const cols = params.columns || '3';
|
||||
return `📷 Gallery (${cols} cols)`;
|
||||
},
|
||||
|
||||
render(params: MacroParams, context: MacroRenderContext): string {
|
||||
const { link, columns = '3', caption } = params;
|
||||
const { columns = '3', caption } = params;
|
||||
const colCount = parseInt(columns, 10) || 3;
|
||||
|
||||
// Build the gallery HTML
|
||||
// Build the gallery HTML with lightbox support
|
||||
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
|
||||
// In production, this would load actual images
|
||||
if (context.isPreview) {
|
||||
html += `<div class="gallery-placeholder">`;
|
||||
html += `<span class="gallery-icon">🖼️</span>`;
|
||||
html += `<span class="gallery-info">Gallery: ${link}</span>`;
|
||||
if (caption) {
|
||||
html += `<span class="gallery-caption">${caption}</span>`;
|
||||
}
|
||||
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} -->`;
|
||||
// Gallery container that will be populated by hydration script
|
||||
// The hydration script uses: window.electronAPI.postMedia.getMediaDataForPost(postId)
|
||||
// and renders images with bds-media:// protocol
|
||||
html += `<div class="gallery-container gallery-lightbox">`;
|
||||
html += `<div class="gallery-loading">Loading gallery...</div>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (caption) {
|
||||
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
// Post-Media Link types
|
||||
export interface MediaLinkData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
postId: string;
|
||||
mediaId: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Chat/AI types
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
@@ -282,6 +292,17 @@ export interface ElectronAPI {
|
||||
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
|
||||
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: {
|
||||
configure: (config: SyncConfig) => Promise<void>;
|
||||
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