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>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NamingConflict } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface NamingCheckModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NamingCheckModal({ open, onClose }: NamingCheckModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const openBook = useBookStore((s) => s.openBook)
|
||||
const namingIgnoreWords = useSettingsStore((s) => s.namingIgnoreWords)
|
||||
const updateSettings = useSettingsStore((s) => s.update)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const reloadTarget = useEditStore((s) => s.reloadTarget)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [conflicts, setConflicts] = useState<NamingConflict[]>([])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const runCheck = async (): Promise<void> => {
|
||||
const activeBookId = useBookStore.getState().currentBookId
|
||||
if (!activeBookId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const rows = await ipcCall(() => window.electronAPI.ai.namingCheck(activeBookId))
|
||||
const ignore = new Set(namingIgnoreWords)
|
||||
setConflicts(
|
||||
rows.filter((row) => !ignore.has(row.termA) && !ignore.has(row.termB))
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
showToast(t('naming.noConflicts'))
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
setConflicts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = (row: NamingConflict): void => {
|
||||
const next = Array.from(new Set([...namingIgnoreWords, row.termA, row.termB]))
|
||||
void updateSettings({ namingIgnoreWords: next })
|
||||
setConflicts((list) => list.filter((item) => item !== row))
|
||||
showToast(t('naming.ignored'))
|
||||
}
|
||||
|
||||
const handleReplace = async (row: NamingConflict): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const canonical = row.suggestedCanonical || row.termA
|
||||
const from = row.termB
|
||||
if (!from || from === canonical) return
|
||||
try {
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.search.replace(bookId, from, canonical, false, { caseSensitive: true })
|
||||
)
|
||||
await openBook(bookId)
|
||||
reloadTarget()
|
||||
setConflicts((list) => list.filter((item) => item !== row))
|
||||
showToast(t('naming.replaced', { count: result.count }))
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay naming-modal" data-testid="naming-check-modal">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('naming.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="naming-modal-body">
|
||||
{conflicts.length === 0 && !loading && (
|
||||
<p className="naming-empty">{t('naming.emptyHint')}</p>
|
||||
)}
|
||||
{conflicts.map((row, index) => (
|
||||
<div key={`${row.termA}-${row.termB}-${index}`} className="naming-row" data-testid="naming-row">
|
||||
<div className="naming-terms">
|
||||
<strong>{row.termA}</strong> ↔ <strong>{row.termB}</strong>
|
||||
</div>
|
||||
<div className="naming-meta">
|
||||
{t('naming.confidence', { value: Math.round(row.confidence * 100) })}
|
||||
{row.reason ? ` · ${row.reason}` : ''}
|
||||
</div>
|
||||
<div className="naming-actions">
|
||||
<button type="button" className="btn" onClick={() => handleIgnore(row)}>
|
||||
{t('naming.ignore')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleReplace(row)}>
|
||||
{t('naming.replace', { term: row.suggestedCanonical || row.termA })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="naming-run-check"
|
||||
disabled={loading || !bookId}
|
||||
onClick={() => void runCheck()}
|
||||
>
|
||||
{loading ? t('naming.running') : t('naming.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user