Files
bilin/src/renderer/App.tsx
T
bing 8ce35a1e8e feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration
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>
2026-07-06 16:33:56 +08:00

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>
)
}