import React, { useEffect, useRef } from 'react'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar, AssistantSidebar } from './components'; import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { loadTabsForProject, saveTabsForProject } from './utils'; import { openSingletonToolTab } from './navigation/tabPolicy'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { persistTranslationValidationReport } from './navigation/translationValidationPersistence'; import { persistDuplicatesResult } from './navigation/duplicatesPersistence'; import { executeActivityClick } from './navigation/activityExecution'; import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling'; import { buildBlogmarkTransformOutputEntries, buildBlogmarkTransformToastNotifications, parseBlogmarkCreatedEventPayload, shouldAutoOpenPanelForOutputEntries, } from './navigation/blogmarkTransformOutput'; import { createDeferredEventGate } from './navigation/deferredEventGate'; import { createAndFocusPost } from './navigation/postCreation'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { addWindowEventListener, BDS_EVENT_SCRIPTS_CHANGED } from './utils/windowEvents'; import { refreshPythonMacroSlugs, wirePythonMacroPreview, invalidatePythonMacroScriptCache } from './macros'; import { useI18n } from './i18n'; import './App.css'; const App: React.FC = () => { const { t: tr } = useI18n(); const { setPosts, setMedia, addPost, updatePost, removePost, addMedia, updateMedia, removeMedia, setTasks, updateTask, setLoading, toggleSidebar, togglePanel, toggleAssistantSidebar, setSelectedPost, setActiveProject, setPicoTheme, openTab, restoreTabState, appendPanelOutputEntry, } = useAppStore(); const blogmarkEventGateRef = useRef(createDeferredEventGate()); const processBlogmarkCreated = (created: PostData) => { addPost(created); const state = useAppStore.getState(); handleBlogmarkCreatedEvent( { activeView: state.activeView, sidebarVisible: state.sidebarVisible, }, created, { setActiveView: state.setActiveView, toggleSidebar: state.toggleSidebar, setSelectedPost: state.setSelectedPost, openTab: state.openTab, }, ); }; // Load initial data useEffect(() => { const loadData = async () => { setLoading(true); try { // First, get active project to set the correct context in backend engines const activeProject = await window.electronAPI?.projects.getActive(); if (activeProject) { setActiveProject(activeProject as import('./store').ProjectData); const metadata = await window.electronAPI?.meta.getProjectMetadata(); setPicoTheme(metadata?.picoTheme); const resolvedTheme = getRendererPicoTheme(metadata?.picoTheme); await ensureRendererPicoThemeStylesheet(resolvedTheme); } // Load posts (now with correct project context, limited to 500) const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 }); if (postsResult) { const { items, hasMore, total } = postsResult as { items: PostData[]; hasMore: boolean; total: number }; setPosts(items, hasMore, total); } // Load media const media = await window.electronAPI?.media.getAll(); if (media) { setMedia(media as MediaData[]); } // Restore tabs for the active project if (activeProject && (activeProject as { id: string }).id) { const savedTabState = loadTabsForProject((activeProject as { id: string }).id); if (savedTabState) { restoreTabState(savedTabState); } } // Load tasks const tasks = await window.electronAPI?.tasks.getAll(); if (tasks) { setTasks(tasks as TaskProgress[]); } // Load known Python macro slugs for editor detection await refreshPythonMacroSlugs(); // Wire Python macro resolver/renderer for editor preview wirePythonMacroPreview(); } catch (error) { console.error('Failed to load initial data:', error); } finally { setLoading(false); setTimeout(() => { blogmarkEventGateRef.current.markReady(processBlogmarkCreated); }, 0); } }; loadData(); }, []); // Save tabs when window closes useEffect(() => { const saveTabsOnUnload = () => { const state = useAppStore.getState(); const projectId = state.activeProject?.id; if (projectId) { const tabState = state.getTabState(); saveTabsForProject(projectId, tabState); } }; window.addEventListener('beforeunload', saveTabsOnUnload); return () => window.removeEventListener('beforeunload', saveTabsOnUnload); }, [tr]); // Set up event listeners for real-time updates useEffect(() => { const unsubscribers: Array<() => void> = []; // Post events unsubscribers.push( window.electronAPI?.on('post:created', (post: unknown) => { addPost(post as PostData); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('post:updated', (post: unknown) => { const p = post as PostData; updatePost(p.id, p); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('post:deleted', (id: unknown) => { removePost(id as string); useAppStore.getState().closeTab(id as string); }) || (() => {}) ); // Post translation events (refresh post to update availableLanguages for sidebar badges) const handlePostTranslationChange = (data: unknown) => { const translation = data as { translationFor?: string }; if (translation.translationFor) { window.electronAPI?.posts.get(translation.translationFor).then((post) => { if (post) { updatePost((post as PostData).id, post as PostData); } }); } }; unsubscribers.push( window.electronAPI?.on('post:translationCreated', handlePostTranslationChange) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('post:translationUpdated', handlePostTranslationChange) || (() => {}) ); // Media events unsubscribers.push( window.electronAPI?.on('media:imported', (media: unknown) => { addMedia(media as MediaData); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('media:updated', (media: unknown) => { const m = media as MediaData; updateMedia(m.id, m); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('media:deleted', (id: unknown) => { removeMedia(id as string); }) || (() => {}) ); // Task events unsubscribers.push( window.electronAPI?.on('task:created', (task: unknown) => { const t = task as TaskProgress; updateTask(t.taskId, t); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('task:started', (task: unknown) => { const t = task as TaskProgress; updateTask(t.taskId, t); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('task:progress', (task: unknown) => { const t = task as TaskProgress; updateTask(t.taskId, t); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('task:completed', (task: unknown) => { const t = task as TaskProgress; updateTask(t.taskId, t); showToast.success(tr('app.taskCompleted', { message: t.message })); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('task:failed', (task: unknown) => { const t = task as TaskProgress; updateTask(t.taskId, t); showToast.error(tr('app.taskFailed', { message: t.error || t.message })); }) || (() => {}) ); // Menu events unsubscribers.push( window.electronAPI?.on('menu:newPost', async () => { const state = useAppStore.getState(); await createAndFocusPost({ createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined, setSelectedPost: state.setSelectedPost, ensurePostsSidebar: () => { const next = useAppStore.getState(); executeActivityClick( { activeView: next.activeView, sidebarVisible: next.sidebarVisible, tabs: next.tabs, activeTabId: next.activeTabId, }, 'posts', { setActiveView: next.setActiveView, toggleSidebar: next.toggleSidebar, }, ); }, onError: (error) => { console.error('Failed to create post:', error); }, }); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('blogmark:created', (payload: unknown) => { const parsedPayload = parseBlogmarkCreatedEventPayload(payload); if (!parsedPayload) { return; } const created = parsedPayload.post as PostData; if (!created?.id) { return; } const outputEntries = buildBlogmarkTransformOutputEntries(parsedPayload.transform, tr); const toastNotifications = buildBlogmarkTransformToastNotifications(parsedPayload.transform, tr); toastNotifications.forEach((notification) => { if (notification.kind === 'error') { showToast.error(notification.message); return; } showToast.success(notification.message); }); if (outputEntries.length > 0) { const createdAt = new Date().toISOString(); outputEntries.forEach((entry, index) => { appendPanelOutputEntry({ id: `blogmark-transform-${Date.now()}-${index}`, createdAt, message: entry.message, kind: entry.kind, }); }); if (shouldAutoOpenPanelForOutputEntries(outputEntries)) { useAppStore.setState({ panelVisible: true, panelActiveTab: 'output', }); } } blogmarkEventGateRef.current.push(created, processBlogmarkCreated); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:importMedia', () => { window.electronAPI?.media.importDialog(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:toggleSidebar', () => { toggleSidebar(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:togglePanel', () => { togglePanel(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:toggleAssistantSidebar', () => { toggleAssistantSidebar(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:viewPosts', () => { const state = useAppStore.getState(); executeActivityClick( { activeView: state.activeView, sidebarVisible: state.sidebarVisible, tabs: state.tabs, activeTabId: state.activeTabId, }, 'posts', { setActiveView: state.setActiveView, toggleSidebar: state.toggleSidebar, }, ); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:viewMedia', () => { const state = useAppStore.getState(); executeActivityClick( { activeView: state.activeView, sidebarVisible: state.sidebarVisible, tabs: state.tabs, activeTabId: state.activeTabId, }, 'media', { setActiveView: state.setActiveView, toggleSidebar: state.toggleSidebar, }, ); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:editPreferences', () => { openSingletonToolTab(openTab, 'settings'); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:editMenu', () => { openSingletonToolTab(openTab, 'menu-editor'); }) || (() => {}) ); // Rebuild events - clear store on start, reload on complete unsubscribers.push( window.electronAPI?.on('posts:rebuildStarted', () => { setPosts([], false, 0); setSelectedPost(null); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('posts:databaseRebuilt', async () => { const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 }); if (postsResult) { const { items, hasMore, total } = postsResult as { items: PostData[]; hasMore: boolean; total: number }; setPosts(items, hasMore, total); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('media:rebuildStarted', () => { setMedia([]); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('media:databaseRebuilt', async () => { const mediaResult = await window.electronAPI?.media.getAll(); if (mediaResult) { setMedia(mediaResult as MediaData[]); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:rebuildDatabase', async () => { try { await Promise.all([ window.electronAPI?.posts.rebuildFromFiles(), window.electronAPI?.media.rebuildFromFiles(), window.electronAPI?.scripts.rebuildFromFiles(), window.electronAPI?.templates.rebuildFromFiles(), ]); await window.electronAPI?.posts.rebuildLinks(); await window.electronAPI?.media.regenerateMissingThumbnails(); } catch (error) { console.error('Database rebuild failed:', error); showToast.error(tr('app.databaseRebuildFailed')); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:reindexText', async () => { try { await Promise.all([ window.electronAPI?.posts.reindexText(), window.electronAPI?.media.reindexText(), ]); } catch (error) { console.error('Text reindex failed:', error); showToast.error(tr('app.textReindexFailed')); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:metadataDiff', () => { openSingletonToolTab(openTab, 'metadata-diff'); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:findDuplicates', () => { openSingletonToolTab(openTab, 'find-duplicates'); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('embeddings:duplicateSearchResult', (...args: unknown[]) => { const pairs = args[0] as import('../main/shared/electronApi').DuplicatePair[]; const projectId = useAppStore.getState().activeProject?.id; if (projectId && pairs) { persistDuplicatesResult(projectId, pairs); window.dispatchEvent(new CustomEvent('bds:duplicates-updated', { detail: { projectId }, })); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:generateSitemap', async () => { try { await window.electronAPI?.blog.generateSitemap(); } catch (error) { console.error('Sitemap generation failed:', error); showToast.error(tr('app.sitemapGenerationFailed')); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:regenerateCalendar', async () => { try { await window.electronAPI?.blog.regenerateCalendar(); } catch (error) { console.error('Calendar regeneration failed:', error); showToast.error(tr('app.calendarRegenerationFailed')); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:validateSite', () => { const validateAndOpen = async () => { try { const report = await window.electronAPI?.blog.validateSite(); const projectId = useAppStore.getState().activeProject?.id; if (projectId && report) { persistSiteValidationReport(projectId, report); window.dispatchEvent(new CustomEvent('bds:site-validation-updated', { detail: { projectId }, })); } openSingletonToolTab(openTab, 'site-validation'); } catch (error) { console.error('Site validation failed:', error); showToast.error(tr('siteValidation.error.validate')); } }; void validateAndOpen(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:validateTranslations', () => { const validateAndOpen = async () => { try { const report = await window.electronAPI?.blog.validateTranslations(); const projectId = useAppStore.getState().activeProject?.id; if (projectId && report) { persistTranslationValidationReport(projectId, report); window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', { detail: { projectId }, })); } openSingletonToolTab(openTab, 'translation-validation'); } catch (error) { console.error('Translation validation failed:', error); showToast.error(tr('translationValidation.error.validate')); } }; void validateAndOpen(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:fillMissingTranslations', () => { const fillMissing = async () => { try { const result = await window.electronAPI?.blog.fillMissingTranslations(); if (result) { if (!result.taskStarted) { showToast.info(tr('blog.fillMissing.nothingToDo')); } else { showToast.success(tr('blog.fillMissing.started')); } } } catch (error) { console.error('Fill missing translations failed:', error); showToast.error(tr('blog.fillMissing.error')); } }; void fillMissing(); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:previewPost', async () => { try { const selectedPostId = useAppStore.getState().selectedPostId; if (!selectedPostId) { return; } const previewUrl = await window.electronAPI?.posts.getPreviewUrl(selectedPostId); if (typeof previewUrl === 'string' && previewUrl.length > 0) { window.open(previewUrl, '_blank', 'noopener'); } } catch (error) { console.error('Failed to open selected post preview:', error); showToast.error(tr('app.previewOpenFailed')); } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:uploadSite', async () => { try { const prefs = await window.electronAPI?.meta.getPublishingPreferences(); if (!prefs) { showToast.error(tr('app.uploadSiteNoCredentials')); return; } if (!prefs.sshHost || !prefs.sshUser || !prefs.sshRemotePath) { showToast.error(tr('app.uploadSiteNoCredentials')); return; } await window.electronAPI?.publish.uploadSite(prefs); } catch (error: any) { console.error('Site upload failed:', error); if (error?.message?.includes('Airplane mode')) { useAppStore.getState().showErrorModal({ message: tr('app.uploadSiteOfflineMode') }); } else { showToast.error(tr('app.uploadSiteFailed')); } } }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:openDocumentation', () => { openSingletonToolTab(openTab, 'documentation'); }) || (() => {}) ); unsubscribers.push( window.electronAPI?.on('menu:openApiDocumentation', () => { openSingletonToolTab(openTab, 'api-documentation'); }) || (() => {}) ); // Import completion event - refresh posts and media stores unsubscribers.push( window.electronAPI?.import.onComplete(async (data) => { // Refresh posts store if any posts were imported if (data.posts.imported > 0 || data.pages.imported > 0) { const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 }); if (postsResult) { const { items, hasMore, total } = postsResult as { items: PostData[]; hasMore: boolean; total: number }; setPosts(items, hasMore, total); } } // Refresh media store if any media was imported if (data.media.imported > 0) { const mediaResult = await window.electronAPI?.media.getAll(); if (mediaResult) { setMedia(mediaResult as MediaData[]); } } // Show success toast const importedCount = data.posts.imported + data.pages.imported; const importedMedia = data.media.imported; if (data.success) { showToast.success(tr('app.importComplete', { posts: importedCount, media: importedMedia })); } }) || (() => {}) ); // Refresh Python macro slugs when scripts change unsubscribers.push( addWindowEventListener(BDS_EVENT_SCRIPTS_CHANGED, () => { invalidatePythonMacroScriptCache(); void refreshPythonMacroSlugs(); }) ); void window.electronAPI?.app.notifyRendererReady?.().catch((error) => { console.error('Failed to notify renderer readiness:', error); }); return () => { unsubscribers.forEach(unsub => unsub()); }; }, []); // Subscribe to entity:changed events fired by the CLI NotificationWatcher. // When the CLI mutates posts or media while the app is open, refresh the // affected entry in the local store so the UI stays in sync. useEffect(() => { const unsub = window.electronAPI?.onEntityChanged(async ({ entity, entityId, action }) => { if (entity === 'post') { if (action === 'deleted') { removePost(entityId); useAppStore.getState().closeTab(entityId); } else { const post = await window.electronAPI?.posts.get(entityId); if (post) { const p = post as PostData; action === 'created' ? addPost(p) : updatePost(p.id, p); } } } else if (entity === 'media') { if (action === 'deleted') { removeMedia(entityId); } else { const media = await window.electronAPI?.media.get(entityId); if (media) { const m = media as MediaData; action === 'created' ? addMedia(m) : updateMedia(m.id, m); } } } // script and template entities have no cached store state — they are // loaded on demand and will reflect CLI changes on next navigation. }); return () => unsub?.(); }, [addPost, updatePost, removePost, addMedia, updateMedia, removeMedia]); const { sidebarVisible, assistantSidebarVisible } = useAppStore(); return (
{sidebarVisible && ( )}
{assistantSidebarVisible && ( )}
); }; export default App;