Files
bilin/src/renderer/components/import/ImportBookModal.tsx
T
bing adf877861d feat: ship v1.1.0 writer toolkit with templates, import, export, and inspiration capture
Implement chapter templates, txt/md/docx import, submission export presets, and global inspiration shortcut. Split import handlers into a separate main bundle and fix EditorLayout setTarget loop that broke E2E.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:53:22 +08:00

234 lines
8.0 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { ipcCall } from '@renderer/lib/ipc-client'
const LARGE_FILE_BYTES = 20 * 1024 * 1024
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function ImportBookModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useAppStore((s) => s.importBookModalOpen)
const setOpen = useAppStore((s) => s.setImportBookModalOpen)
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { loadBooks, openBook } = useBookStore()
const { openBookTab } = useTabStore()
const [filePath, setFilePath] = useState('')
const [fileName, setFileName] = useState('')
const [bookName, setBookName] = useState('')
const [category, setCategory] = useState('玄幻')
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
const [customRegex, setCustomRegex] = useState('')
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
const [importing, setImporting] = useState(false)
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
useEffect(() => {
if (!open) return
setFilePath('')
setFileName('')
setBookName('')
setCategory('玄幻')
setSplitMode('auto')
setCustomRegex('')
setPreview(null)
setProgress(null)
}, [open])
useEffect(() => {
if (!open) return
const unsub = window.electronAPI.onImportProgress((payload) => {
setProgress(payload)
})
return unsub
}, [open])
const handlePickFile = async (): Promise<void> => {
const result = await ipcCall(() => window.electronAPI.import.pickFile())
if (result.canceled || !result.filePath) return
setFilePath(result.filePath)
const name = result.filePath.split(/[/\\]/).pop() ?? ''
setFileName(name)
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
if (!bookName.trim()) setBookName(baseName)
}
const handlePreview = async (): Promise<void> => {
if (!filePath) return
const result = await ipcCall(() =>
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
)
setPreview(result)
}
const handleImport = async (): Promise<void> => {
if (!filePath || !bookName.trim()) return
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
const ok = confirm(
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
)
if (!ok) return
}
setImporting(true)
setProgress(null)
try {
if (!preview) await handlePreview()
const bookId = await ipcCall(() =>
window.electronAPI.import.execute({
filePath,
bookName: bookName.trim(),
category,
splitMode,
customRegex: customRegex || undefined
})
)
await loadBooks()
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
await openBook(bookId)
openBookTab(bookId, meta?.name ?? bookName.trim())
setView('editor')
showToast(t('import.success'))
setOpen(false)
} catch {
showToast(t('import.failed'))
} finally {
setImporting(false)
setProgress(null)
}
}
if (!open) return null
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
return (
<div
className="modal-overlay"
id="importModal"
data-testid="import-book-modal"
role="dialog"
aria-modal="true"
aria-label={t('import.title')}
>
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('import.title')}</h3>
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
</button>
</div>
<div className="modal-body">
<div className="import-file-row">
<input
className="form-control"
readOnly
value={fileName || t('import.noFile')}
data-testid="import-file-path"
/>
<button
type="button"
className="btn"
data-testid="import-pick-file"
onClick={() => void handlePickFile()}
>
{t('import.pickFile')}
</button>
</div>
<label className="form-label">{t('dialog.newBook.name')}</label>
<input
className="form-control form-control--wide"
data-testid="import-book-name"
value={bookName}
onChange={(e) => setBookName(e.target.value)}
/>
<label className="form-label">{t('dialog.newBook.category')}</label>
<input
className="form-control form-control--wide"
data-testid="import-book-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
<label className="form-label">{t('import.splitMode')}</label>
<select
className="form-control form-control--wide"
data-testid="import-split-mode"
value={splitMode}
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
>
<option value="auto">{t('import.splitAuto')}</option>
<option value="paragraph">{t('import.splitParagraph')}</option>
<option value="regex">{t('import.splitRegex')}</option>
</select>
{splitMode === 'regex' && (
<>
<label className="form-label">{t('import.customRegex')}</label>
<input
className="form-control form-control--wide"
data-testid="import-custom-regex"
value={customRegex}
onChange={(e) => setCustomRegex(e.target.value)}
placeholder="^第.+章"
/>
</>
)}
<button
type="button"
className="btn"
data-testid="import-preview-btn"
disabled={!filePath}
onClick={() => void handlePreview()}
>
{t('import.preview')}
</button>
{preview && (
<div className="import-preview" data-testid="import-preview">
<p>
{t('import.chapterCount', { count: preview.chapters.length })}
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
</p>
<ul className="import-preview-list">
{previewTitles.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
</div>
)}
{progress && (
<div className="import-progress" data-testid="import-progress-bar">
<div
className="import-progress-fill"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="import-execute-btn"
disabled={!filePath || !bookName.trim() || importing}
onClick={() => void handleImport()}
>
{importing ? t('import.importing') : t('import.start')}
</button>
</div>
</div>
</div>
)
}