Files
hohoff/src/renderer/App.tsx
2026-06-14 18:34:43 +10:00

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-label">Pack</span>
</button>
<button
className={`sidebar-nav-btn${showSubmissions ? ' active' : ''}`}
onClick={goSubmissions}
title="Submissions"
>
<span className="sidebar-nav-label">Sub</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>
)
}