291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { ExportDialog } from './components/Export/ExportDialog'
|
||
import type { ExportOptions } from './components/Export/ExportDialog'
|
||
import { FileTree } from './components/FileTree/FileTree'
|
||
import { currentEditorView } from './components/Editor/MarkdownEditor'
|
||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||
import { DocumentOutline } from './components/Editor/DocumentOutline'
|
||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
|
||
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
||
import { HomeScreen } from './components/Home/HomeScreen'
|
||
import { SubmissionsScreen } from './components/Submissions/SubmissionsScreen'
|
||
import { useEditorStore } from './store/editorStore'
|
||
import './styles/app.css'
|
||
|
||
export default function App(): JSX.Element {
|
||
const {
|
||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
|
||
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode,
|
||
showHome, goHome, setActiveFile, showSubmissions, goSubmissions
|
||
} = useEditorStore()
|
||
|
||
const handleOpenStoryBible = async (): Promise<void> => {
|
||
try {
|
||
const { path, content } = await window.api.openStoryBible()
|
||
setActiveFile(path, content)
|
||
if (path === activeFilePath) {
|
||
const view = currentEditorView
|
||
if (view) {
|
||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } })
|
||
markSaved()
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[App] openStoryBible failed:', err)
|
||
}
|
||
}
|
||
|
||
const handleOpenPublisherPack = async (): Promise<void> => {
|
||
try {
|
||
const { path, content } = await window.api.openPublisherPack()
|
||
setActiveFile(path, content)
|
||
} catch (err) {
|
||
console.error('[App] openPublisherPack failed:', err)
|
||
}
|
||
}
|
||
const [sidebarOpen, setSidebarOpen] = useState(
|
||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||
)
|
||
const [chatOpen, setChatOpen] = useState(
|
||
() => localStorage.getItem('chatOpen') !== 'false'
|
||
)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
const [exportOpen, setExportOpen] = useState(false)
|
||
const [isFirstRun, setIsFirstRun] = useState(false)
|
||
const [focusPeek, setFocusPeek] = useState(false)
|
||
const [projectTitle, setProjectTitle] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!focusMode) {
|
||
setFocusPeek(false)
|
||
return
|
||
}
|
||
const onMove = (e: MouseEvent): void => setFocusPeek(e.clientY < 85)
|
||
window.addEventListener('mousemove', onMove)
|
||
return () => window.removeEventListener('mousemove', onMove)
|
||
}, [focusMode])
|
||
|
||
const switchProject = async (newPath: string): Promise<void> => {
|
||
await window.api.writeConfig({ projectPath: newPath })
|
||
setProjectTitle(newPath.split('/').filter(Boolean).pop() ?? '')
|
||
clearActiveFile()
|
||
const files = await window.api.listFiles()
|
||
setFileTree(files)
|
||
await loadSession()
|
||
}
|
||
|
||
// Load file tree, restore prefs + session, and detect first run.
|
||
// Draft-directory calls are sequenced so macOS only prompts for folder access once.
|
||
useEffect(() => {
|
||
async function init(): Promise<void> {
|
||
initPrefs()
|
||
const files = await window.api.listFiles()
|
||
setFileTree(files)
|
||
await loadSession()
|
||
const cfg = await window.api.readConfig()
|
||
if (!cfg.apiKey || !cfg.projectPath) {
|
||
setIsFirstRun(true)
|
||
setSettingsOpen(true)
|
||
}
|
||
if (cfg.projectPath) {
|
||
setProjectTitle(cfg.projectPath.split('/').filter(Boolean).pop() ?? '')
|
||
}
|
||
}
|
||
init()
|
||
}, [])
|
||
|
||
// Handle menu actions sent from the main process
|
||
useEffect(() => {
|
||
return window.api.onMenuAction(async (action) => {
|
||
if (action === 'save') {
|
||
if (activeFilePath && isDirty) {
|
||
await window.api.writeFile(activeFilePath, activeFileContent)
|
||
await window.api.saveRevision(activeFilePath, activeFileContent)
|
||
markSaved()
|
||
}
|
||
} else if (action === 'toggleSidebar') {
|
||
setSidebarOpen((v) => { const n = !v; localStorage.setItem('sidebarOpen', String(n)); return n })
|
||
} else if (action === 'toggleChat') {
|
||
setChatOpen((v) => { const n = !v; localStorage.setItem('chatOpen', String(n)); return n })
|
||
} else if (action === 'toggleRevisions') {
|
||
toggleRevisionPanel()
|
||
} else if (action === 'toggleTheme') {
|
||
toggleTheme()
|
||
} else if (action === 'fontIncrease') {
|
||
setFontSize(fontSize + 1)
|
||
} else if (action === 'fontDecrease') {
|
||
setFontSize(fontSize - 1)
|
||
} else if (action === 'fontReset') {
|
||
setFontSize(15)
|
||
} else if (action === 'projectSearch') {
|
||
openProjectSearch()
|
||
} else if (action === 'openSettings') {
|
||
setSettingsOpen(true)
|
||
} else if (action === 'toggleFocusMode') {
|
||
toggleFocusMode()
|
||
} else if (action === 'openProject') {
|
||
const picked = await window.api.pickProjectFolder()
|
||
if (picked) await switchProject(picked)
|
||
} else if (action.startsWith('openRecent:')) {
|
||
await switchProject(action.slice('openRecent:'.length))
|
||
} else if (action === 'exportPDF') {
|
||
if (activeFilePath && activeFileContent) {
|
||
const fileName = activeFilePath.split('/').pop()?.replace(/\.md$/, '') ?? 'document'
|
||
await window.api.exportPDF(activeFileContent, fileName)
|
||
}
|
||
} else if (action === 'exportProjectPDF') {
|
||
setExportOpen(true)
|
||
}
|
||
})
|
||
}, [activeFilePath, isDirty, activeFileContent, fontSize])
|
||
|
||
// Handle Cmd+S / Ctrl+S and Cmd+Shift+F / Ctrl+Shift+F
|
||
useEffect(() => {
|
||
const handler = async (e: KeyboardEvent): Promise<void> => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||
e.preventDefault()
|
||
if (activeFilePath && isDirty) {
|
||
await window.api.writeFile(activeFilePath, activeFileContent)
|
||
await window.api.saveRevision(activeFilePath, activeFileContent)
|
||
markSaved()
|
||
}
|
||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||
e.preventDefault()
|
||
openProjectSearch()
|
||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'g') {
|
||
e.preventDefault()
|
||
toggleFocusMode()
|
||
} else if (e.key === 'Escape' && focusMode) {
|
||
toggleFocusMode()
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handler)
|
||
return () => window.removeEventListener('keydown', handler)
|
||
}, [activeFilePath, isDirty, activeFileContent, focusMode])
|
||
|
||
return (
|
||
<div
|
||
className="app-layout"
|
||
data-sidebar={sidebarOpen ? 'open' : 'closed'}
|
||
data-chat={chatOpen ? 'open' : 'closed'}
|
||
data-focus={focusMode ? 'on' : 'off'}
|
||
data-peek={focusPeek ? 'on' : 'off'}
|
||
>
|
||
<div className="app-titlebar">
|
||
<span className="app-titlebar-title">{projectTitle || 'Hohoff Editor'}</span>
|
||
<div className="app-titlebar-right">
|
||
<div className="app-layout-toggle">
|
||
<button
|
||
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
|
||
onClick={() => setSidebarOpen((v) => { const next = !v; localStorage.setItem('sidebarOpen', String(next)); return next })}
|
||
title={sidebarOpen ? 'Hide file tree' : 'Show file tree'}
|
||
disabled={focusMode}
|
||
/>
|
||
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
|
||
<button
|
||
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
|
||
onClick={() => setChatOpen((v) => { const next = !v; localStorage.setItem('chatOpen', String(next)); return next })}
|
||
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
|
||
disabled={focusMode}
|
||
/>
|
||
</div>
|
||
<div className="app-titlebar-icon-group">
|
||
<button
|
||
className={`app-titlebar-theme-btn${focusMode ? ' active' : ''}`}
|
||
onClick={toggleFocusMode}
|
||
title="Focus mode (⌘⇧G)"
|
||
>⊡</button>
|
||
<button
|
||
className="app-titlebar-theme-btn"
|
||
onClick={toggleTheme}
|
||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||
>
|
||
{theme === 'dark' ? '☀' : '☾'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<aside className="sidebar">
|
||
<div className="sidebar-nav">
|
||
<button
|
||
className={`sidebar-nav-btn${showHome ? ' active' : ''}`}
|
||
onClick={goHome}
|
||
title="Home"
|
||
>
|
||
<span className="sidebar-nav-icon">⌂</span>
|
||
</button>
|
||
<button
|
||
className="sidebar-nav-btn"
|
||
onClick={handleOpenStoryBible}
|
||
title="Story Bible"
|
||
>
|
||
<span className="sidebar-nav-icon">📖</span>
|
||
</button>
|
||
<button
|
||
className="sidebar-nav-btn"
|
||
onClick={handleOpenPublisherPack}
|
||
title="Publisher Pack"
|
||
>
|
||
<span className="sidebar-nav-icon">📋</span>
|
||
</button>
|
||
<button
|
||
className={`sidebar-nav-btn${showSubmissions ? ' active' : ''}`}
|
||
onClick={goSubmissions}
|
||
title="Submissions"
|
||
>
|
||
<span className="sidebar-nav-icon">✉️</span>
|
||
</button>
|
||
</div>
|
||
<FileTree />
|
||
</aside>
|
||
<main className="editor-area" style={{ position: 'relative' }}>
|
||
{showHome ? (
|
||
<HomeScreen />
|
||
) : showSubmissions ? (
|
||
<SubmissionsScreen />
|
||
) : (
|
||
<>
|
||
<AnalysisToolbar />
|
||
<div className="editor-body">
|
||
<div className="page-backdrop" />
|
||
<DocumentOutline />
|
||
<MarkdownEditor />
|
||
</div>
|
||
{revisionPanelOpen && <RevisionPanel />}
|
||
</>
|
||
)}
|
||
</main>
|
||
<aside className="chat-area">
|
||
<ChatPanel />
|
||
</aside>
|
||
|
||
<ProjectSearchModal />
|
||
{settingsOpen && (
|
||
<SettingsDialog
|
||
onClose={() => { setSettingsOpen(false); setIsFirstRun(false) }}
|
||
isSetup={isFirstRun}
|
||
onProjectChanged={async () => {
|
||
clearActiveFile()
|
||
const files = await window.api.listFiles()
|
||
setFileTree(files)
|
||
await loadSession()
|
||
}}
|
||
/>
|
||
)}
|
||
{exportOpen && (
|
||
<ExportDialog
|
||
onClose={() => setExportOpen(false)}
|
||
onExport={async (opts: ExportOptions) => {
|
||
setExportOpen(false)
|
||
await window.api.exportProjectPDF(opts)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|