import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import MonacoEditor, { Monaco } from '@monaco-editor/react'; import { useAppStore, PostData, EditorMode, MediaData } from '../../store'; 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 { ConfirmDeleteModal } from '../ConfirmDeleteModal'; import { SettingsView } from '../SettingsView'; import { TagsView } from '../TagsView'; import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; 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 './Editor.css'; /** Get display name for media: prefer title over originalName */ function getMediaDisplayName(media: { title?: string; originalName: string }): string { return media.title || media.originalName; } // Module-level AutoSaveManager for idle-time based auto-saving const autoSaveManager = new AutoSaveManager({ idleTimeMs: 3000, // Save after 3 seconds of idle time onSave: async (id, changes) => { // Note: We don't check if post exists in store's posts array since that's limited to 500. // If the post was deleted, the update will fail gracefully. // Build update payload from changes const update: Parameters[1] = {}; if ('title' in changes) update.title = changes.title as string; if ('content' in changes) update.content = changes.content as string; if ('tags' in changes) { const tagsStr = changes.tags as string; update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0); } if ('categories' in changes) { update.categories = changes.categories as string[]; } const updated = await window.electronAPI?.posts.update(id, update); if (updated) { useAppStore.getState().updatePost(id, updated as Partial); useAppStore.getState().markClean(id); // Emit event so PostEditor can update its local state window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id, updated } })); } }, onSaveComplete: (id) => { console.log(`Auto-saved post ${id}`); }, onSaveError: (id, error) => { console.error(`Auto-save failed for ${id}:`, error); }, }); /** * Resolves media references in markdown content to bds-media:// URLs * Matches images by: * 1. Media ID in the path (e.g., /media/2025/01/{id}.jpg) * 2. Original filename (e.g., image.jpg) * 3. Filename pattern (e.g., {id}.jpg) */ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => { if (!content || mediaList.length === 0) return content; // Build lookup maps for efficient matching const byId = new Map(); const byOriginalName = new Map(); const byFilename = new Map(); for (const m of mediaList) { byId.set(m.id, m.id); byOriginalName.set(m.originalName.toLowerCase(), m.id); byFilename.set(m.filename.toLowerCase(), m.id); } // Replace image URLs in markdown return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { // Skip if already using bds-media protocol or external URLs if (src.startsWith('bds-media://') || src.startsWith('http://') || src.startsWith('https://')) { return match; } // Extract the filename from the path const filename = src.split('/').pop() || ''; const filenameWithoutExt = filename.replace(/\.[^.]+$/, ''); const filenameLower = filename.toLowerCase(); // Try to match by: // 1. UUID in path (the file is named by ID) if (byId.has(filenameWithoutExt)) { return `![${alt}](bds-media://${filenameWithoutExt})`; } // 2. Filename lookup if (byFilename.has(filenameLower)) { return `![${alt}](bds-media://${byFilename.get(filenameLower)})`; } // 3. Original name lookup if (byOriginalName.has(filenameLower)) { return `![${alt}](bds-media://${byOriginalName.get(filenameLower)})`; } // No match found, return original return match; }); }; // 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 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, '

$1

') .replace(/^## (.*$)/gim, '

$1

') .replace(/^# (.*$)/gim, '

$1

') // Bold .replace(/\*\*(.*?)\*\*/gim, '$1') // Italic .replace(/\*(.*?)\*/gim, '$1') // Images .replace(/!\[(.*?)\]\((.*?)\)/gim, '$1') // Links .replace(/\[(.*?)\]\((.*?)\)/gim, '$1') // Code blocks .replace(/```([\s\S]*?)```/gim, '
$1
') // Inline code .replace(/`(.*?)`/gim, '$1') // Blockquotes .replace(/^\> (.*$)/gim, '
$1
') // Horizontal rules .replace(/^---$/gim, '
') // Line breaks .replace(/\n/g, '
'); }; /** * 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; } export const PostEditor: React.FC = ({ postId }) => { const { updatePost, markDirty, markClean, isDirty: checkIsDirty, preferredEditorMode, setPreferredEditorMode, showErrorModal, showConfirmDeleteModal, media, closeTab, } = useAppStore(); // Fetch full post data from backend const [post, setPost] = useState(null); const [isLoadingPost, setIsLoadingPost] = useState(true); // Track whether form state has been initialized from post data const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { let cancelled = false; setIsLoadingPost(true); setIsInitialized(false); window.electronAPI?.posts.get(postId).then((fetchedPost) => { if (cancelled) return; if (fetchedPost) { setPost(fetchedPost as PostData); // Also update the store so other components have the full data useAppStore.getState().updatePost(postId, fetchedPost as Partial); } else { // Post doesn't exist, close the tab closeTab(postId); } setIsLoadingPost(false); }); return () => { cancelled = true; }; }, [postId, closeTab]); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [author, setAuthor] = useState(''); const [tags, setTags] = useState([]); const [selectedCategories, setSelectedCategories] = useState(['article']); const [availableCategories, setAvailableCategories] = useState(['article', 'picture', 'aside', 'page']); const [categoriesDropdownOpen, setCategoriesDropdownOpen] = useState(false); 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 [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); // Listen for auto-save events to keep local post state in sync useEffect(() => { const handler = (e: Event) => { const { id, updated } = (e as CustomEvent).detail; if (id === postId && updated) { setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); } }; window.addEventListener('bds:post-auto-saved', handler); return () => window.removeEventListener('bds:post-auto-saved', handler); }, [postId]); // Check if post has a published version for discard functionality useEffect(() => { window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion); }, [postId]); // Load available categories from backend (project-scoped) useEffect(() => { const loadCategories = async () => { try { const categories = await window.electronAPI?.meta.getCategories(); if (categories && categories.length > 0) { setAvailableCategories(categories); } } catch { // Keep defaults } }; loadCategories(); }, []); // Close categories dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (categoriesDropdownRef.current && !categoriesDropdownRef.current.contains(event.target as Node)) { setCategoriesDropdownOpen(false); } }; if (categoriesDropdownOpen) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [categoriesDropdownOpen]); // Resolve media URLs in content for display const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]); // 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; 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); if (!cancelled) { await hydratePhotoArchive(previewRef.current, lightboxHandler); } } finally { // Always reset hydration state when complete - the ref is global to the component isHydratingRef.current = false; showOverlay(false); } }, 100); return () => { cancelled = true; clearTimeout(timer); }; }, [editorMode, postId, resolvedContent]); // Track latest values for auto-save on unmount/switch const pendingChangesRef = useRef<{ title: string; content: string; tags: string[]; categories: string[]; postId: string; isDirty: boolean; } | null>(null); // Update ref when values change useEffect(() => { pendingChangesRef.current = { title, content, tags, categories: selectedCategories, postId, isDirty, }; }, [title, content, tags, selectedCategories, postId, isDirty]); // Auto-save when switching away from a post or unmounting useEffect(() => { return () => { // Cancel any pending auto-save timer - we'll save immediately autoSaveManager.cancel(postId); const pending = pendingChangesRef.current; // Auto-save if we have pending changes (the update will fail gracefully if post was deleted) if (pending && pending.postId === postId && pending.isDirty) { // Fire and forget auto-save window.electronAPI?.posts.update(pending.postId, { title: pending.title, content: pending.content, tags: pending.tags, categories: pending.categories.length > 0 ? pending.categories : ['article'], }).then((updated) => { if (updated) { useAppStore.getState().updatePost(pending.postId, updated as Partial); useAppStore.getState().markClean(pending.postId); window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id: pending.postId, updated } })); } }).catch((error) => { console.error('Auto-save failed:', error); }); } }; }, [postId]); // Reset when post data is loaded or changes useEffect(() => { if (post) { setTitle(post.title); setContent(post.content); setAuthor(post.author || ''); setTags(post.tags); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); markClean(postId); // Mark as initialized AFTER setting local state setIsInitialized(true); } }, [post, postId, markClean]); // Track changes and notify auto-save manager // Only run after form has been initialized from post data useEffect(() => { if (!post || !isInitialized) return; const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()); const categoriesChanged = JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); const authorChanged = author !== (post.author || ''); const hasChanges = title !== post.title || content !== post.content || authorChanged || tagsChanged || categoriesChanged; if (hasChanges) { markDirty(postId); // Notify auto-save manager with accumulated changes // Convert tags array to comma-separated string for auto-save compatibility autoSaveManager.notifyChange(postId, { title, content, author, tags: tags.join(', '), categories: selectedCategories, }); } else { markClean(postId); } }, [title, content, author, tags, selectedCategories, post, postId, isInitialized, markDirty, markClean]); // Handle editor mode change and persist preference const handleEditorModeChange = (mode: EditorMode) => { setEditorMode(mode); setPreferredEditorMode(mode); }; const handleSave = useCallback(async () => { if (!isDirty || isSaving) return; // Cancel any pending auto-save since we're saving manually autoSaveManager.cancel(postId); setIsSaving(true); try { const updated = await window.electronAPI?.posts.update(postId, { title, content, author: author || undefined, tags, categories: selectedCategories.length > 0 ? selectedCategories : ['article'], }); if (updated) { updatePost(postId, updated as Partial); setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); markClean(postId); } } catch (error) { console.error('Failed to save post:', error); const err = error as Error; showErrorModal({ title: 'Save Failed', message: err.message || 'Failed to save post', stack: err.stack, }); } finally { setIsSaving(false); } }, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]); const handlePublish = async () => { await handleSave(); try { const updated = await window.electronAPI?.posts.publish(postId); if (updated) { updatePost(postId, updated as Partial); setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); showToast.success('Post published'); } } catch (error) { console.error('Failed to publish post:', error); const err = error as Error; showErrorModal({ title: 'Publish Failed', message: err.message || 'Failed to publish post', stack: err.stack, }); } }; const handleDiscard = async () => { // If this post has a published version, revert to it // If never published, delete the post entirely const confirmMessage = hasPublishedVersion ? 'Discard all changes since last publish? This cannot be undone.' : 'Delete this draft? This cannot be undone.'; if (!confirm(confirmMessage)) { return; } try { if (hasPublishedVersion) { // Revert to published version const reverted = await window.electronAPI?.posts.discard(postId); if (reverted) { setTitle(reverted.title); setContent(reverted.content); setAuthor(reverted.author || ''); setTags(reverted.tags); setSelectedCategories(reverted.categories.length > 0 ? reverted.categories : ['article']); // Update local post state so UI reflects the published status setPost(reverted as PostData); updatePost(postId, reverted as Partial); markClean(postId); showToast.success('Reverted to last published version'); } } else { // Never published - delete the post entirely await window.electronAPI?.posts.delete(postId); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(postId); useAppStore.getState().closeTab(postId); showToast.success('Draft deleted'); } } catch (error) { console.error('Failed to discard/delete:', error); const err = error as Error; showErrorModal({ title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed', message: err.message || 'Operation failed', stack: err.stack, }); } }; const handleDelete = async () => { try { // Fetch references to this post const [linkedBy, linkedMedia] = await Promise.all([ window.electronAPI?.posts.getLinkedBy(postId), window.electronAPI?.postMedia.getForPost(postId), ]); // Build references array const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = []; // Add posts that link to this post if (linkedBy && linkedBy.length > 0) { linkedBy.forEach((p: { id: string; title: string }) => { references.push({ id: p.id, title: p.title, type: 'link' }); }); } // Add linked media if (linkedMedia && linkedMedia.length > 0) { linkedMedia.forEach((m: { mediaId: string }) => { const mediaItem = media.find(item => item.id === m.mediaId); if (mediaItem) { references.push({ id: mediaItem.id, title: getMediaDisplayName(mediaItem), type: 'media', }); } }); } // Show confirmation modal showConfirmDeleteModal({ itemType: 'post', itemTitle: title || 'Untitled', references, onConfirm: async () => { try { await window.electronAPI?.posts.delete(postId); // Clear pending ref to prevent auto-save on unmount from resurrecting the post pendingChangesRef.current = null; useAppStore.getState().removePost(postId); useAppStore.getState().closeTab(postId); useAppStore.getState().setSelectedPost(null); showToast.success('Post deleted'); } catch (error) { console.error('Failed to delete post:', error); const err = error as Error; showErrorModal({ title: 'Delete Failed', message: err.message || 'Failed to delete post', stack: err.stack, }); } }, }); } catch (error) { console.error('Failed to fetch post references:', error); const err = error as Error; showErrorModal({ title: 'Error', message: err.message || 'Failed to fetch post references', stack: err.stack, }); } }; // Handle Monaco editor mount const handleEditorDidMount = (editor: unknown, monaco: Monaco) => { editorRef.current = editor; const ed = editor as any; // Add keyboard shortcut and command for inserting post links ed.addAction({ id: 'editor.action.insertPostLink', label: 'Insert Link to Post', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], run: () => { setShowPostSearch(true); } }); }; // Handle link insertion from InsertModal (for posts or external URLs) const handleInsertLink = useCallback((url: string, text?: string) => { const editor = editorRef.current as any; if (!editor) return; const model = editor.getModel(); if (!model) return; const selection = editor.getSelection(); const selectedText = selection ? model.getValueInRange(selection) : ''; const linkText = text || selectedText || url; const linkMarkdown = `[${linkText}](${url})`; editor.executeEdits('insert-link', [{ range: selection || editor.getSelection(), text: linkMarkdown, forceMoveMarkers: true }]); setShowPostSearch(false); }, []); // Handle image insertion from InsertModal (for media library) const handleInsertImage = useCallback(async (url: string, alt: string, mediaId?: string) => { const editor = editorRef.current as any; if (!editor) return; const selection = editor.getSelection(); const imageMarkdown = `![${alt}](${url})`; editor.executeEdits('insert-image', [{ range: selection || editor.getSelection(), text: imageMarkdown, forceMoveMarkers: true }]); // Link the media to this post if mediaId is provided (from media library) if (mediaId) { try { await window.electronAPI?.postMedia.link(postId, mediaId); console.log(`[Editor] Linked media ${mediaId} to post ${postId}`); } catch (error) { console.error('Failed to link media to post:', error); } } setShowMediaSearch(false); }, [postId]); // Configure Monaco before mount to add macro syntax highlighting const handleEditorWillMount = (monaco: Monaco) => { // Register a custom language that extends markdown with macro support monaco.languages.register({ id: 'markdown-with-macros' }); // Define custom tokenization that highlights [[macro]] syntax monaco.languages.setMonarchTokensProvider('markdown-with-macros', { defaultToken: '', tokenPostfix: '.md', tokenizer: { root: [ // Macro syntax: [[macroName param="value"]] [/\[\[[a-zA-Z][\w-]*/, { token: 'keyword.macro', next: '@macroParams' }], // Headers [/^#{1,6}\s.*$/, 'keyword.header'], // Block elements [/^\s*>+/, 'string.quote'], [/^\s*[-+*]\s/, 'keyword'], [/^\s*\d+\.\s/, 'keyword'], [/^\s*```\w*/, { token: 'string.code', next: '@codeblock' }], // Inline elements [/\*\*[^*]+\*\*/, 'strong'], [/\*[^*]+\*/, 'emphasis'], [/__[^_]+__/, 'strong'], [/_[^_]+_/, 'emphasis'], [/`[^`]+`/, 'variable'], // Links and images [/!?\[[^\]]*\]\([^)]*\)/, 'string.link'], [/!?\[[^\]]*\]\[[^\]]*\]/, 'string.link'], ], macroParams: [ [/\]\]/, { token: 'keyword.macro', next: '@root' }], [/[a-zA-Z][\w-]*(?=\s*=)/, 'attribute.name'], [/=/, 'delimiter'], [/"[^"]*"/, 'string'], [/\s+/, 'white'], [/[^\]"=\s]+/, 'attribute.value'], ], codeblock: [ [/^\s*```\s*$/, { token: 'string.code', next: '@root' }], [/.*$/, 'variable.source'], ], }, }); // Define theme colors for macros monaco.editor.defineTheme('vs-dark-macros', { base: 'vs-dark', inherit: true, rules: [ { token: 'keyword.macro', foreground: 'C586C0', fontStyle: 'bold' }, { token: 'attribute.name', foreground: '9CDCFE' }, { token: 'attribute.value', foreground: 'CE9178' }, ], colors: {}, }); }; // Save on Ctrl+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSave]); // Listen for menu events useEffect(() => { const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave); const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish); return () => { unsubscribeSave?.(); unsubscribePublish?.(); }; }, [handleSave]); // Show loading state while fetching post data if (isLoadingPost || !post) { return (

Loading post...

); } return (
{title || 'Untitled'} {isDirty && }
{post.status} {isSaving && Saving...} {post.status === 'draft' && ( )} {post.status === 'draft' && ( )} {post.status === 'published' && ( )}
setTitle(e.target.value)} placeholder="Untitled" />
setAuthor(e.target.value)} placeholder="Author name" />
{categoriesDropdownOpen && (
{availableCategories.map((cat) => ( ))}
)}
{selectedCategories.length > 1 && (
{selectedCategories.map((cat) => ( {cat} ))}
)}
useAppStore.getState().setSelectedPost(id)} />
{images.length > 0 && ( )} {editorMode === 'markdown' && ( <> )}
{editorMode === 'wysiwyg' && ( )} {editorMode === 'markdown' && ( setContent(value || '')} onMount={handleEditorDidMount} beforeMount={handleEditorWillMount} theme="vs-dark-macros" options={{ minimap: { enabled: false }, wordWrap: 'on', lineNumbers: 'on', fontSize: 14, fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", padding: { top: 12, bottom: 12 }, automaticLayout: true, scrollBeyondLastLine: false, renderLineHighlight: 'line', quickSuggestions: false, formatOnPaste: true, cursorStyle: 'line', cursorBlinking: 'smooth', }} /> )} {editorMode === 'preview' && (
Loading photo archive...
)}
{/* Lightbox for viewing images in content */} { setLightboxOpen(false); setGalleryImages([]); }} />
Created: {new Date(post.createdAt).toLocaleString()} Updated: {new Date(post.updatedAt).toLocaleString()} {post.publishedAt && ( Published: {new Date(post.publishedAt).toLocaleString()} )}
{showPostSearch && ( {}} onClose={() => setShowPostSearch(false)} /> )} {showMediaSearch && ( {}} onClose={() => setShowMediaSearch(false)} /> )}
); }; const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore(); const item = media.find(m => m.id === mediaId); const [title, setTitle] = useState(item?.title || ''); const [alt, setAlt] = useState(item?.alt || ''); const [caption, setCaption] = useState(item?.caption || ''); const [author, setAuthor] = useState(item?.author || ''); const [tags, setTags] = useState(item?.tags.join(', ') || ''); const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]); const [postTitles, setPostTitles] = useState>(new Map()); const [showPostPicker, setShowPostPicker] = useState(false); const [postSearchQuery, setPostSearchQuery] = useState(''); const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]); // Quick action menu state const [showQuickActions, setShowQuickActions] = useState(false); const [projectLanguage, setProjectLanguage] = useState('en'); const quickActionsRef = useRef(null); // AI suggestions modal state const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [aiSuggestions, setAISuggestions] = useState(null); const [aiError, setAIError] = useState(undefined); // Load project language setting useEffect(() => { window.electronAPI?.meta.getProjectMetadata().then(metadata => { if (metadata?.mainLanguage) { setProjectLanguage(metadata.mainLanguage); } }); }, []); // Close quick actions menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) { setShowQuickActions(false); } }; if (showQuickActions) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showQuickActions]); // Handle AI image analysis for alt text and caption const handleAIAnalysis = async () => { if (!item || isAnalyzing) return; setShowQuickActions(false); setShowAISuggestionsModal(true); setIsAnalyzing(true); setAISuggestions(null); setAIError(undefined); try { const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage); if (result?.success) { setAISuggestions({ title: result.title, alt: result.alt, caption: result.caption, }); } else { setAIError(result?.error || 'Failed to analyze image'); } } catch (error) { console.error('Failed to analyze image:', error); setAIError((error as Error).message || 'Failed to analyze image'); } finally { setIsAnalyzing(false); } }; // Handle applying AI suggestions const handleApplyAISuggestions = (values: Partial) => { if (values.title) setTitle(values.title); if (values.alt) setAlt(values.alt); if (values.caption) setCaption(values.caption); setShowAISuggestionsModal(false); if (Object.keys(values).length > 0) { showToast.success('AI suggestions applied'); } }; // Close AI suggestions modal const handleCloseAISuggestionsModal = () => { setShowAISuggestionsModal(false); setAISuggestions(null); setAIError(undefined); }; // Load linked posts for this media and fetch their titles 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 }))); // Fetch titles for linked posts const titles = new Map(); for (const link of links) { const post = await window.electronAPI?.posts.get(link.postId); if (post) { titles.set(link.postId, post.title || 'Untitled'); } } setPostTitles(titles); } } catch (error) { console.error('Failed to load linked posts:', error); } }; loadLinkedPosts(); }, [mediaId]); // Fetch posts for the picker when it opens useEffect(() => { if (!showPostPicker) return; const loadPickerPosts = async () => { try { const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 }); if (result?.items) { setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || 'Untitled' }))); } } catch (error) { console.error('Failed to load posts for picker:', error); } }; loadPickerPosts(); }, [showPostPicker]); // Get post titles for display const getPostTitle = (postId: string): string => { return postTitles.get(postId) || 'Loading...'; }; // Handle linking to a new post const handleLinkToPost = async (postId: string, postTitle: string) => { try { await window.electronAPI?.postMedia.link(postId, mediaId); setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]); setPostTitles(prev => new Map(prev).set(postId, postTitle)); setShowPostPicker(false); setPostSearchQuery(''); 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, filtered by search const unlinkedPosts = pickerPosts.filter( p => !linkedPosts.find(l => l.postId === p.id) ).filter( p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase()) ); useEffect(() => { if (item) { setTitle(item.title || ''); setAlt(item.alt || ''); setCaption(item.caption || ''); setAuthor(item.author || ''); setTags(item.tags.join(', ')); } }, [item?.id]); if (!item) { return
Media not found
; } const handleSave = async () => { try { const updated = await window.electronAPI?.media.update(item.id, { title, alt, caption, author: author || undefined, tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), }); if (updated) { updateMedia(item.id, updated as Partial); showToast.success('Media updated'); } } catch (error) { console.error('Failed to update media:', error); const err = error as Error; showErrorModal({ title: 'Update Failed', message: err.message || 'Failed to update media', stack: err.stack, }); } }; const handleReplaceFile = async () => { try { const updated = await window.electronAPI?.media.replaceFileDialog(item.id); if (updated) { updateMedia(item.id, updated as Partial); showToast.success('File replaced (thumbnails regenerated)'); } // null means user cancelled or file unchanged - no action needed } catch (error) { console.error('Failed to replace media file:', error); const err = error as Error; showErrorModal({ title: 'Replace Failed', message: err.message || 'Failed to replace media file', stack: err.stack, }); } }; const handleDelete = async () => { try { // Fetch posts that link to this media const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId); // Build references array const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = []; // Add posts that use this media - fetch titles from database if (linkedPostsList && linkedPostsList.length > 0) { for (const link of linkedPostsList) { const post = await window.electronAPI?.posts.get(link.postId); if (post) { references.push({ id: post.id, title: post.title || 'Untitled', type: 'post', }); } } } // Show confirmation modal showConfirmDeleteModal({ itemType: 'media', itemTitle: getMediaDisplayName(item), references, onConfirm: async () => { try { await window.electronAPI?.media.delete(item.id); useAppStore.getState().removeMedia(item.id); showToast.success('Media deleted'); } catch (error) { console.error('Failed to delete media:', error); const err = error as Error; showErrorModal({ title: 'Delete Failed', message: err.message || 'Failed to delete media', stack: err.stack, }); } }, }); } catch (error) { console.error('Failed to fetch media references:', error); const err = error as Error; showErrorModal({ title: 'Error', message: err.message || 'Failed to fetch media references', stack: err.stack, }); } }; return (
{getMediaDisplayName(item)}
{/* Quick Actions Dropdown */} {item.mimeType.startsWith('image/') && (
{showQuickActions && (
)}
)}
{item.mimeType.startsWith('image/') ? (
{item.alt { // Fallback to placeholder if image fails to load const target = e.target as HTMLImageElement; target.style.display = 'none'; target.parentElement?.classList.add('has-error'); }} />
) : (
{item.originalName}
)}
{item.width && item.height && (
)}
setTitle(e.target.value)} placeholder="Title for lists and search results" />
setAlt(e.target.value)} placeholder="Describe the image for accessibility" />