8ce35a1e8e
Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Provider as JotaiProvider } from 'jotai'
|
|
import { useTranslation } from 'react-i18next'
|
|
import './i18n'
|
|
import './styles/themes.css'
|
|
import './styles/globals.css'
|
|
import './styles/layout.css'
|
|
import { TopBar } from '@renderer/components/layout/TopBar'
|
|
import { HomePage } from '@renderer/components/home/HomePage'
|
|
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
|
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
|
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'
|
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
|
import { SearchModal } from '@renderer/components/search/SearchModal'
|
|
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
|
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
|
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
|
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
|
|
|
function AppInner(): React.JSX.Element {
|
|
const { t } = useTranslation()
|
|
const view = useAppStore((s) => s.view)
|
|
const toast = useAppStore((s) => s.toast)
|
|
const showToast = useAppStore((s) => s.showToast)
|
|
const setView = useAppStore((s) => s.setView)
|
|
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
|
const loadBooks = useBookStore((s) => s.loadBooks)
|
|
const { activeTabId, openTabs } = useTabStore()
|
|
const openBook = useBookStore((s) => s.openBook)
|
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
await loadSettings()
|
|
await loadBooks()
|
|
})()
|
|
}, [loadSettings, loadBooks])
|
|
|
|
useEffect(() => {
|
|
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
|
}, [loaded, onboardingCompleted])
|
|
|
|
useEffect(() => {
|
|
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
|
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
|
const openInspiration = (): void => {
|
|
if (useAppStore.getState().view !== 'editor') return
|
|
if (!useBookStore.getState().currentBookId) return
|
|
useAppStore.getState().setInspirationModalOpen(true)
|
|
}
|
|
const onInsertLandmark = (e: Event): void => {
|
|
const label = (e as CustomEvent<{ label: string }>).detail?.label
|
|
if (!label) return
|
|
const { currentBookId } = useBookStore.getState()
|
|
const target = useEditStore.getState().target
|
|
if (!currentBookId || target?.kind !== 'chapter') return
|
|
insertLandmarkInEditor({ label, landmarkType: 'todo' })
|
|
void ipcCall(() =>
|
|
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo')
|
|
)
|
|
}
|
|
const onSetAiOnline = (e: Event): void => {
|
|
const online = (e as CustomEvent<{ online: boolean }>).detail?.online
|
|
if (typeof online === 'boolean') useAiStore.setState({ online })
|
|
}
|
|
const onFlushEditor = (): void => {
|
|
void flushEditorSave()
|
|
}
|
|
window.addEventListener('bilin:e2e-open-search', openSearch)
|
|
window.addEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
|
window.addEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
|
window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
|
window.addEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
|
window.addEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
|
return () => {
|
|
window.removeEventListener('bilin:e2e-open-search', openSearch)
|
|
window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
|
window.removeEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
|
window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
|
window.removeEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
|
window.removeEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
|
if (action === 'saveChapter') {
|
|
try {
|
|
await flushEditorSave(true)
|
|
} catch {
|
|
showToast(t('toast.saveFailed'))
|
|
}
|
|
return
|
|
}
|
|
if (action === 'newChapter') {
|
|
if (view !== 'editor') return
|
|
const { currentBookId, activeVolumeId, refreshChapters, setSelectedChapter } = useBookStore.getState()
|
|
if (!currentBookId || !activeVolumeId) return
|
|
await flushEditorSave()
|
|
const ch = await ipcCall(() =>
|
|
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
|
)
|
|
await refreshChapters()
|
|
setSelectedChapter(ch.id)
|
|
return
|
|
}
|
|
if (action === 'closeTab') {
|
|
const tab = openTabs.find((x) => x.id === activeTabId)
|
|
if (!tab) return
|
|
const next = useTabStore.getState().closeTab(tab.id)
|
|
if (next) {
|
|
const nt = useTabStore.getState().openTabs.find((x) => x.id === next)
|
|
if (nt?.type === 'book' && nt.bookId) {
|
|
await openBook(nt.bookId)
|
|
setView('editor')
|
|
} else setView('settings')
|
|
} else setView('home')
|
|
return
|
|
}
|
|
if (action === 'globalSearch') {
|
|
useSearchStore.getState().setOpen(true)
|
|
return
|
|
}
|
|
if (action === 'openReference') {
|
|
useReferenceStore.getState().setOpen(true)
|
|
return
|
|
}
|
|
if (action === 'openLandmarks') {
|
|
useAppStore.getState().setLandmarksModalOpen(true)
|
|
return
|
|
}
|
|
if (action === 'insertLandmark') {
|
|
if (view !== 'editor') return
|
|
document.querySelector<HTMLButtonElement>('[data-testid="insert-landmark"]')?.click()
|
|
return
|
|
}
|
|
if (action === 'captureInspiration') {
|
|
if (view !== 'editor') return
|
|
if (!useBookStore.getState().currentBookId) return
|
|
useAppStore.getState().setInspirationModalOpen(true)
|
|
return
|
|
}
|
|
showToast(t('feature.comingSoon'))
|
|
})
|
|
return unsub
|
|
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
|
|
|
const handleOnboardingComplete = (): void => {
|
|
setShowOnboarding(false)
|
|
}
|
|
|
|
return (
|
|
<div id="app">
|
|
<TopBar />
|
|
<div id="main-area">
|
|
{view === 'home' && <HomePage />}
|
|
{view === 'editor' && <EditorLayout />}
|
|
{view === 'settings' && <SettingsPage />}
|
|
</div>
|
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
|
<SearchModal />
|
|
<InspirationModal />
|
|
{toast && <div className="toast">{toast}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App(): React.JSX.Element {
|
|
return (
|
|
<JotaiProvider>
|
|
<AppInner />
|
|
</JotaiProvider>
|
|
)
|
|
}
|