diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 139228c..59e45ae 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -63,6 +63,25 @@ const PREVIEW_ASSETS = { }, } as const; +const PREVIEW_IMAGE_ASSETS = { + 'prev.png': { + modulePath: 'lightbox2/dist/images/prev.png', + contentType: 'image/png', + }, + 'next.png': { + modulePath: 'lightbox2/dist/images/next.png', + contentType: 'image/png', + }, + 'close.png': { + modulePath: 'lightbox2/dist/images/close.png', + contentType: 'image/png', + }, + 'loading.gif': { + modulePath: 'lightbox2/dist/images/loading.gif', + contentType: 'image/gif', + }, +} as const; + function clampMaxPostsPerPage(value: unknown): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return DEFAULT_MAX_POSTS_PER_PAGE; @@ -415,6 +434,12 @@ export class PreviewServer { return; } + const imageAsset = await this.resolveImageAsset(pathname); + if (imageAsset) { + this.respondAsset(res, imageAsset.contentType, imageAsset.body); + return; + } + const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir); if (mediaAsset) { this.respondAsset(res, mediaAsset.contentType, mediaAsset.body); @@ -705,6 +730,27 @@ export class PreviewServer { } } + private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { + const match = pathname.match(/^\/images\/([^/]+)$/); + if (!match) return null; + + const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS; + const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName]; + if (!assetDefinition) return null; + + try { + const absolutePath = require.resolve(assetDefinition.modulePath); + const body = await readFile(absolutePath); + return { + contentType: assetDefinition.contentType, + body, + }; + } catch (error) { + console.error(`[PreviewServer] Failed to read image asset: ${assetDefinition.modulePath}`, error); + return null; + } + } + private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> { const match = pathname.match(/^\/media\/(.+)$/); if (!match || !dataDir) return null; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d3bddd1..f67c7fb 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -30,6 +30,22 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise): }); } +function buildCanonicalPreviewPath(createdAt: Date, slug: string): string { + const year = createdAt.getFullYear(); + const month = String(createdAt.getMonth() + 1).padStart(2, '0'); + const day = String(createdAt.getDate()).padStart(2, '0'); + return `/${year}/${month}/${day}/${slug}`; +} + +function resolvePostCreatedAt(post: { createdAt: Date | string }): Date { + if (post.createdAt instanceof Date) { + return post.createdAt; + } + + const parsed = new Date(post.createdAt); + return Number.isNaN(parsed.getTime()) ? new Date() : parsed; +} + export function registerIpcHandlers(): void { // ============ Git Handlers ============ @@ -256,6 +272,19 @@ export function registerIpcHandlers(): void { return engine.getPost(id); }); + safeHandle('posts:getPreviewUrl', async (_, id: string) => { + const engine = getPostEngine(); + const post = await engine.getPost(id); + + if (!post) { + return null; + } + + const createdAt = resolvePostCreatedAt(post); + const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); + return `http://127.0.0.1:4123${canonicalPath}`; + }); + safeHandle('posts:getAll', async (_, options?: PaginationOptions) => { const engine = getPostEngine(); return engine.getAllPosts(options); diff --git a/src/main/main.ts b/src/main/main.ts index 7af1f10..a52a337 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,11 +6,14 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea import { media } from './database/schema'; import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; +import { getPostEngine } from './engine/PostEngine'; import { PreviewServer } from './engine/PreviewServer'; let mainWindow: BrowserWindow | null = null; let previewServer: PreviewServer | null = null; +let activePreviewPostId: string | null = null; const PREVIEW_SERVER_PORT = 4123; +const BLOG_PREVIEW_POST_MENU_ID = 'blog.previewPost'; // Check if dev server is likely running (only in development) const isDev = process.env.NODE_ENV === 'development'; @@ -109,6 +112,42 @@ async function openPreviewInBrowser(): Promise { await shell.openExternal(`${previewServer.getBaseUrl()}/`); } +function buildCanonicalPostPath(createdAt: Date, slug: string): string { + const year = createdAt.getFullYear(); + const month = String(createdAt.getMonth() + 1).padStart(2, '0'); + const day = String(createdAt.getDate()).padStart(2, '0'); + return `/${year}/${month}/${day}/${slug}`; +} + +function setPreviewPostMenuEnabled(enabled: boolean): void { + const appMenu = Menu.getApplicationMenu(); + const previewPostMenuItem = appMenu?.getMenuItemById(BLOG_PREVIEW_POST_MENU_ID); + if (previewPostMenuItem) { + previewPostMenuItem.enabled = enabled; + } +} + +async function openActivePostPreviewInBrowser(): Promise { + if (!activePreviewPostId) { + return; + } + + const postEngine = getPostEngine(); + const post = await postEngine.getPost(activePreviewPostId); + if (!post) { + setPreviewPostMenuEnabled(false); + return; + } + + if (!previewServer) { + previewServer = new PreviewServer(); + } + + await previewServer.start(PREVIEW_SERVER_PORT); + const canonicalPath = buildCanonicalPostPath(post.createdAt, post.slug); + await shell.openExternal(`${previewServer.getBaseUrl()}${canonicalPath}`); +} + async function startPreviewServerOnAppStart(): Promise { if (!previewServer) { previewServer = new PreviewServer(); @@ -265,9 +304,15 @@ function createApplicationMenu(): Menu { { type: 'separator' }, { label: 'Preview Post', + id: BLOG_PREVIEW_POST_MENU_ID, + enabled: false, accelerator: 'CmdOrCtrl+Shift+V', - click: () => { - mainWindow?.webContents.send('menu:previewPost'); + click: async () => { + try { + await openActivePostPreviewInBrowser(); + } catch (error) { + console.error('Failed to preview active post in browser:', error); + } }, }, { type: 'separator' }, @@ -444,6 +489,11 @@ async function initialize(): Promise { // Register IPC handlers registerIpcHandlers(); + + ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => { + activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null; + setPreviewPostMenuEnabled(Boolean(activePreviewPostId)); + }); // Initialize and register chat handlers initializeChatHandlers(() => mainWindow); diff --git a/src/main/preload.ts b/src/main/preload.ts index be5b667..16dd948 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -52,6 +52,7 @@ export const electronAPI: ElectronAPI = { update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data), delete: (id: string) => ipcRenderer.invoke('posts:delete', id), get: (id: string) => ipcRenderer.invoke('posts:get', id), + getPreviewUrl: (id: string) => ipcRenderer.invoke('posts:getPreviewUrl', id), getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options), getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status), publish: (id: string) => ipcRenderer.invoke('posts:publish', id), @@ -140,6 +141,7 @@ export const electronAPI: ElectronAPI = { selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title), getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId), readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), + setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), }, // Meta (tags, categories, and project metadata) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index f6cb8fd..5f1fe37 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -430,6 +430,7 @@ export interface ElectronAPI { update: (id: string, data: Partial) => Promise; delete: (id: string) => Promise; get: (id: string) => Promise; + getPreviewUrl: (id: string) => Promise; getAll: (options?: { limit?: number; offset?: number }) => Promise; getByStatus: (status: string) => Promise; publish: (id: string) => Promise; @@ -508,6 +509,7 @@ export interface ElectronAPI { selectFolder: (title?: string) => Promise; getDefaultProjectPath: (projectId: string) => Promise; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; + setPreviewPostTarget: (postId: string | null) => Promise; }; meta: { getTags: () => Promise; diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index d0b48e4..797583a 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -193,10 +193,31 @@ } .editor-toolbar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.editor-toolbar-left { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 8px; + justify-content: flex-start; +} + +.editor-toolbar-center { + display: flex; + align-items: center; + justify-content: center; +} + +.editor-toolbar-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: 0; } .editor-mode-toggle { @@ -233,7 +254,6 @@ border: none; cursor: pointer; transition: background-color 0.15s; - margin-left: auto; } .gallery-button:hover { @@ -261,107 +281,26 @@ flex: 1; background-color: var(--vscode-input-background); border-radius: 4px; - padding: 16px; - overflow-y: auto; - font-size: 14px; - line-height: 1.6; + overflow: hidden; position: relative; } -/* Hydration loading overlay */ -.preview-hydrating-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(30, 30, 30, 0.85); +.editor-preview-frame { + width: 100%; + height: 100%; + border: none; + background: var(--vscode-editor-background); +} + +.editor-preview-loading { + width: 100%; + height: 100%; display: flex; align-items: center; justify-content: center; - z-index: 10; - border-radius: 4px; -} - -.preview-hydrating-content { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - color: var(--vscode-foreground); - font-size: 14px; -} - -.preview-hydrating-spinner { - width: 32px; - height: 32px; - border: 3px solid var(--vscode-panel-border); - border-top-color: var(--vscode-focusBorder); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.editor-preview .preview-content { - max-width: 800px; - margin: 0 auto; -} - -.editor-preview h1, -.editor-preview h2, -.editor-preview h3 { - margin-top: 1.5em; - margin-bottom: 0.5em; - color: var(--vscode-foreground); -} - -.editor-preview h1 { font-size: 2em; } -.editor-preview h2 { font-size: 1.5em; } -.editor-preview h3 { font-size: 1.25em; } - -.editor-preview code { - background-color: var(--vscode-textCodeBlock-background); - padding: 2px 6px; - border-radius: 3px; - font-family: var(--vscode-editor-font-family); -} - -.editor-preview pre { - background-color: var(--vscode-textCodeBlock-background); - padding: 12px; - border-radius: 6px; - overflow-x: auto; -} - -.editor-preview pre code { - background: none; - padding: 0; -} - -.editor-preview blockquote { - border-left: 3px solid var(--vscode-textBlockQuote-border); - padding-left: 16px; - margin-left: 0; color: var(--vscode-descriptionForeground); } -.editor-preview a { - color: var(--vscode-textLink-foreground); -} - -.editor-preview a:hover { - color: var(--vscode-textLink-activeForeground); -} - -.editor-preview img { - max-width: 100%; - border-radius: 6px; - cursor: pointer; -} - .editor-field-row { display: flex; gap: 12px; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 7fe9bff..b888f63 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -16,10 +16,8 @@ import { ImportAnalysisView } from '../ImportAnalysisView'; import { MetadataDiffPanel } from '../MetadataDiffPanel'; import { GitDiffView } from '../GitDiffView/GitDiffView'; import { AutoSaveManager, getContrastColor } from '../../utils'; -import { parseMacros, getMacro } from '../../macros/registry'; import { InsertModal } from '../InsertModal'; import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; -import { marked } from 'marked'; import './Editor.css'; /** Get display name for media: prefer title over originalName */ @@ -116,380 +114,6 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => { }); }; -// Render a macro synchronously for preview -const renderMacroSync = (name: string, params: Record, postId?: string): string => { - const macro = getMacro(name); - if (!macro) { - return `Unknown macro: ${name}`; - } - 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 `
Loading ${name}...
`; - } - return result; - } catch (e) { - return `Error rendering ${name}`; - } -}; - -// Simple markdown to HTML converter for preview -export 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 marked.parse(result, { - gfm: true, - breaks: false, - async: false, - }) as string; -}; - -/** - * 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 linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); - - if (!linkedData || linkedData.length === 0) { - galleryContainer.innerHTML = ''; - continue; - } - - // Filter to images only (media is nested in the link object) - const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/')); - - if (images.length === 0) { - galleryContainer.innerHTML = ''; - continue; - } - - // Build gallery grid (column count is handled via CSS class on parent) - galleryContainer.innerHTML = images.map((link, index) => ` - - `).join(''); - - // Set up lightbox click handlers - const items = galleryContainer.querySelectorAll('.gallery-item'); - const imageData = images.map(link => ({ - src: `bds-media://${link.media.id}`, - alt: link.media.alt || link.media.originalName, - })); - - items.forEach((item, index) => { - item.addEventListener('click', () => onImageClick(index, imageData)); - }); - - } catch (error) { - console.error('Failed to hydrate gallery:', error); - galleryContainer.innerHTML = ''; - } - } -}; - -const FULL_MONTH_NAMES = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' -]; - -// Track photo_archive hydration state to prevent duplicate runs -const photoArchiveHydratingCache = new WeakSet(); - -/** - * Hydrate photo_archive elements in the preview with actual media from the given year/month. - */ -const hydratePhotoArchive = async ( - container: HTMLElement, - onImageClick: (index: number, images: { src: string; alt: string }[]) => void -) => { - // Match both year-based and recent-based archives - const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]'); - - if (archives.length === 0) { - return; - } - - // Check if we're already hydrating (prevent duplicate runs) - if (photoArchiveHydratingCache.has(container)) { - console.log('[photo_archive] Skipping duplicate hydration for container'); - return; - } - photoArchiveHydratingCache.add(container); - - try { - await doHydratePhotoArchive(container, onImageClick, archives); - } finally { - // Clear the hydrating flag after a delay to allow for content changes - setTimeout(() => photoArchiveHydratingCache.delete(container), 500); - } -}; - -/** - * Internal implementation of photo_archive hydration - */ -const doHydratePhotoArchive = async ( - _container: HTMLElement, - onImageClick: (index: number, images: { src: string; alt: string }[]) => void, - archives: NodeListOf -) => { - // Collect media for archive rendering based on current macros. - type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date }; - const archiveData: Array<{ - element: Element; - mode: 'single-month' | 'full-year' | 'recent'; - year?: number; - month?: number; - images?: ImageData[]; - // Map key is "YYYY-MM" for recent mode, or month number (1-12) for year mode - monthlyImages?: Map; - showYearInLabel?: boolean; - }> = []; - - console.log(`[photo_archive] Processing ${archives.length} archive macro(s)`); - - for (const archive of archives) { - const recentStr = archive.getAttribute('data-recent'); - const yearStr = archive.getAttribute('data-year'); - const monthStr = archive.getAttribute('data-month'); - - if (recentStr) { - // Recent mode: get last N months with images - const recentCount = parseInt(recentStr, 10) || 10; - console.log(`[photo_archive] Recent mode: fetching last ${recentCount} months with images`); - - // Fetch all images (no filter) - const allMedia = await window.electronAPI?.media.filter({}); - const allImages = (allMedia || []).filter(m => m.mimeType?.startsWith('image/')); - - // Group by year-month and sort by most recent - const monthlyMap = new Map(); - for (const img of allImages) { - if (!img.createdAt) continue; - const date = new Date(img.createdAt); - const year = date.getFullYear(); - const month = date.getMonth() + 1; // 1-based - const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06" - - if (!monthlyMap.has(key)) { - monthlyMap.set(key, []); - } - monthlyMap.get(key)!.push(img); - } - - // Sort by key descending (newest first) and take top N - const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount); - const recentMonthlyImages = new Map(); - - for (const key of sortedKeys) { - const images = monthlyMap.get(key)!; - recentMonthlyImages.set(key, images); - } - - const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0); - archiveData.push({ - element: archive, - mode: 'recent', - monthlyImages: recentMonthlyImages, - showYearInLabel: true - }); - console.log(`[photo_archive] Recent: ${totalImages} images across ${recentMonthlyImages.size} months`); - - } else if (yearStr) { - const year = parseInt(yearStr, 10); - const month = monthStr ? parseInt(monthStr, 10) : undefined; - - if (!year) continue; - - if (month !== undefined) { - // Single month view - const mediaItems = await window.electronAPI?.media.filter({ - year, - month: month - 1, // API uses 0-based month - }); - const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); - - archiveData.push({ element: archive, mode: 'single-month', year, month, images }); - console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`); - } else { - // Full year view - collect all months, tracking which month each image belongs to - const monthlyImages = new Map(); - - for (let m = 0; m < 12; m++) { - const mediaItems = await window.electronAPI?.media.filter({ - year, - month: m, - }); - const images = (mediaItems || []).filter(item => item.mimeType?.startsWith('image/')); - - if (images.length > 0) { - monthlyImages.set(m + 1, images); // Store with 1-based month key - } - } - - const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0); - archiveData.push({ element: archive, mode: 'full-year', year, month: undefined, monthlyImages }); - console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`); - } - } - } - - // Render galleries - for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) { - const archiveContainer = element.querySelector('.photo-archive-container'); - if (!archiveContainer) continue; - - try { - // Render the gallery - let html = ''; - - if (mode === 'single-month' && month !== undefined && images && year) { - // Single month view - if (images.length === 0) { - archiveContainer.innerHTML = `
No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}
`; - continue; - } - html = buildMonthGallery(month, year, images, onImageClick, false); - } else if (mode === 'recent' && monthlyImages) { - // Recent mode - keys are "YYYY-MM" strings - if (monthlyImages.size === 0) { - archiveContainer.innerHTML = `
No recent photos found
`; - continue; - } - - // Sort by key descending (newest first) - keys are "YYYY-MM" strings - const sortedEntries = Array.from(monthlyImages.entries()) - .sort((a, b) => (b[0] as string).localeCompare(a[0] as string)); - - html = sortedEntries.map(([key, imgs]) => { - // Parse "YYYY-MM" to get year and month - const [yearStr, monthStr] = (key as string).split('-'); - const entryYear = parseInt(yearStr, 10); - const entryMonth = parseInt(monthStr, 10); - return `
${buildMonthGallery(entryMonth, entryYear, imgs, onImageClick, true)}
`; - }).join(''); - } else if (mode === 'full-year' && monthlyImages && year) { - // Full year view - keys are month numbers - if (monthlyImages.size === 0) { - archiveContainer.innerHTML = `
No photos found for ${year}
`; - continue; - } - - // Sort months ascending (January first) - const sortedMonths = Array.from(monthlyImages.entries()) - .sort((a, b) => (a[0] as number) - (b[0] as number)); - html = sortedMonths.map(([m, imgs]) => - `
${buildMonthGallery(m as number, year, imgs, onImageClick, showYearInLabel || false)}
` - ).join(''); - } - - archiveContainer.innerHTML = html; - - // Set up click handlers for all images - setupPhotoArchiveClickHandlers(archiveContainer, onImageClick); - - } catch (error) { - console.error('Failed to hydrate photo archive:', error); - archiveContainer.innerHTML = '
Failed to load photo archive
'; - } - } - - console.log('[photo_archive] Hydration complete.'); -}; - -/** - * Build HTML for a single month's gallery with rotated month label - * @param month - 1-based month number (1 = January) - * @param year - The year - * @param images - Array of image data - * @param _onImageClick - Click handler (unused in template, set up separately) - * @param showYear - Whether to include the year in the label (e.g., "January 2024") - */ -function buildMonthGallery( - month: number, - year: number, - images: { id: string; originalName: string; alt?: string }[], - _onImageClick: (index: number, images: { src: string; alt: string }[]) => void, - showYear: boolean = false -): string { - const monthName = FULL_MONTH_NAMES[month - 1]; - const labelText = showYear ? `${monthName} ${year}` : monthName; - - return ` -
-
- ${labelText} -
- -
- `; -} - -/** - * Set up click handlers for photo archive gallery items - */ -function setupPhotoArchiveClickHandlers( - container: Element, - onImageClick: (index: number, images: { src: string; alt: string }[]) => void -) { - // Find all month galleries - const monthGalleries = container.querySelectorAll('.photo-archive-month'); - - monthGalleries.forEach(monthGallery => { - const items = monthGallery.querySelectorAll('.photo-archive-item'); - const imageData = Array.from(items).map(item => { - const img = item.querySelector('img'); - return { - src: img?.getAttribute('src') || '', - alt: img?.getAttribute('alt') || '', - }; - }); - - items.forEach((item, index) => { - item.addEventListener('click', () => onImageClick(index, imageData)); - }); - }); -} - interface PostEditorProps { postId: string; } @@ -543,17 +167,13 @@ export const PostEditor: React.FC = ({ postId }) => { const categoriesDropdownRef = useRef(null); const [isSaving, setIsSaving] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false); - const hydrationOverlayRef = useRef(null); - const isHydratingRef = useRef(false); - const previewContentRef = useRef(null); const [editorMode, setEditorMode] = useState(preferredEditorMode); + const [previewUrl, setPreviewUrl] = useState(null); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); - const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]); const [showPostSearch, setShowPostSearch] = useState(false); const [showMediaSearch, setShowMediaSearch] = useState(false); const editorRef = useRef(null); - const previewRef = useRef(null); const isDirty = checkIsDirty(postId); @@ -607,67 +227,30 @@ export const PostEditor: React.FC = ({ postId }) => { // 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 and photo archives when in preview mode useEffect(() => { - if (editorMode !== 'preview' || !previewRef.current || !previewContentRef.current) return; - + if (editorMode !== 'preview') return; + let cancelled = false; - - // Helper to show/hide the overlay without triggering React re-render - const showOverlay = (show: boolean) => { - if (hydrationOverlayRef.current) { - hydrationOverlayRef.current.style.display = show ? 'flex' : 'none'; - } - }; - - // Set content immediately if not hydrating - // During hydration, we skip updating to preserve the hydrated DOM - if (!isHydratingRef.current) { - previewContentRef.current.innerHTML = markdownToHtml(resolvedContent, postId); - } - - // Small delay to ensure DOM is updated - const timer = setTimeout(async () => { - if (cancelled || !previewRef.current) return; - - const lightboxHandler = (index: number, imgs: { src: string; alt: string }[]) => { - setGalleryImages(imgs); - setLightboxIndex(index); - setLightboxOpen(true); - }; - - // Check if there are photo_archive macros that need hydration - const hasPhotoArchives = previewRef.current.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]').length > 0; - - if (hasPhotoArchives) { - isHydratingRef.current = true; - showOverlay(true); - } - - try { - await hydrateGalleries(previewRef.current, postId, lightboxHandler); + setPreviewUrl(null); + + window.electronAPI?.posts.getPreviewUrl(postId) + .then((url) => { if (!cancelled) { - await hydratePhotoArchive(previewRef.current, lightboxHandler); + setPreviewUrl(url); } - } finally { - // Always reset hydration state when complete - the ref is global to the component - isHydratingRef.current = false; - showOverlay(false); - } - }, 100); - + }) + .catch((error) => { + console.error('Failed to load post preview URL:', error); + if (!cancelled) { + setPreviewUrl(null); + } + }); + return () => { cancelled = true; - clearTimeout(timer); }; - }, [editorMode, postId, resolvedContent]); + }, [editorMode, postId]); // Track latest values for auto-save on unmount/switch const pendingChangesRef = useRef<{ @@ -1264,57 +847,63 @@ export const PostEditor: React.FC = ({ postId }) => {
- -
- - - +
+
- {images.length > 0 && ( - - )} - {editorMode === 'markdown' && ( - <> - - - - )} + +
+
+
+ {images.length > 0 && ( + + )} + {editorMode === 'markdown' && ( + <> + + + + )} +
{editorMode === 'wysiwyg' && ( @@ -1353,27 +942,26 @@ export const PostEditor: React.FC = ({ postId }) => { )} {editorMode === 'preview' && ( -
-
-
-
- Loading photo archive... -
-
-
+
+ {previewUrl ? ( +