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>
This commit is contained in:
+10
-4
@@ -17,11 +17,13 @@ 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 { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
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 { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
@@ -57,8 +59,6 @@ function AppInner(): React.JSX.Element {
|
||||
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 => {
|
||||
@@ -148,11 +148,16 @@ function AppInner(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
if (action === 'captureInspiration') {
|
||||
if (view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'exportBook') {
|
||||
if (view !== 'editor') return
|
||||
const target = useEditStore.getState().target
|
||||
if (target?.kind !== 'chapter') return
|
||||
useExportStore.getState().openModal()
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
@@ -173,6 +178,7 @@ function AppInner(): React.JSX.Element {
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||
import {
|
||||
bodyPlainToChapterHtml,
|
||||
replaceChapterTemplate,
|
||||
type TemplateContext
|
||||
} from '@shared/chapter-template'
|
||||
import type { ChapterTemplate } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
|
||||
interface TemplateQuickCreateModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TemplateQuickCreateModal({
|
||||
open,
|
||||
onClose
|
||||
}: TemplateQuickCreateModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const penName = useSettingsStore((s) => s.penName)
|
||||
const customTemplates = useSettingsStore((s) => s.chapterTemplates)
|
||||
const {
|
||||
currentBookId,
|
||||
activeVolumeId,
|
||||
volumes,
|
||||
chapters,
|
||||
refreshChapters,
|
||||
setSelectedChapter
|
||||
} = useBookStore()
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const templates = useMemo(
|
||||
() => mergeChapterTemplates(customTemplates ?? []),
|
||||
[customTemplates]
|
||||
)
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>(templates[0]?.id ?? '')
|
||||
const [chapterTitle, setChapterTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(templates[0]?.id ?? '')
|
||||
setChapterTitle('')
|
||||
}, [open, templates])
|
||||
|
||||
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
|
||||
|
||||
const buildContext = (title: string): TemplateContext => {
|
||||
const volChapters = chapters
|
||||
.filter((c) => c.volumeId === activeVolumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterNumber = volChapters.length + 1
|
||||
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
|
||||
const previousChapterTitle =
|
||||
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
|
||||
return {
|
||||
chapterNumber,
|
||||
chapterTitle: title,
|
||||
penName,
|
||||
date: new Date().toLocaleDateString('zh-CN'),
|
||||
volumeName,
|
||||
previousChapterTitle,
|
||||
outlineItem: '',
|
||||
characterList: ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await flushEditorSave()
|
||||
const title = chapterTitle.trim()
|
||||
const ctx = buildContext(title)
|
||||
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
|
||||
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
|
||||
|
||||
const ch = await ipcCall(() =>
|
||||
window.electronAPI.chapter.create(currentBookId, activeVolumeId, title)
|
||||
)
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId: currentBookId,
|
||||
chapterId: ch.id,
|
||||
content: contentHtml,
|
||||
...(selectedTemplate.defaultStatus ? { status: selectedTemplate.defaultStatus } : {})
|
||||
})
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(updated.id)
|
||||
await switchTarget({ kind: 'chapter', id: updated.id })
|
||||
onClose()
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="templateModal"
|
||||
data-testid="template-quick-create-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('template.quickNew')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('template.quickNew')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('template.selectTemplate')}</label>
|
||||
<div className="template-grid">
|
||||
{templates.map((tpl: ChapterTemplate) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
className={`template-card ${tpl.id === selectedId ? 'active' : ''}`}
|
||||
data-testid={`template-card-${tpl.id}`}
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
>
|
||||
<span className="template-card-name">{tpl.name}</span>
|
||||
{tpl.builtin && <span className="template-badge">{t('template.builtin')}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="form-label">{t('template.chapterTitle')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="template-chapter-title"
|
||||
value={chapterTitle}
|
||||
onChange={(e) => setChapterTitle(e.target.value)}
|
||||
placeholder={t('template.chapterTitlePlaceholder')}
|
||||
/>
|
||||
</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="template-create-btn"
|
||||
disabled={!chapterTitle.trim() || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{t('template.createChapter')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function ExportModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useExportStore((s) => s.open)
|
||||
const close = useExportStore((s) => s.close)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
|
||||
|
||||
const presets = useMemo(() => mergeSubmissionPresets(customPresets ?? []), [customPresets])
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : null
|
||||
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
|
||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPresetId(defaultPresetId)
|
||||
}, [open, defaultPresetId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !chapterId) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
void ipcCall(() => window.electronAPI.export.formatChapter(bookId, chapterId, presetId))
|
||||
.then((text) => setPreview(text.slice(0, 500)))
|
||||
.catch(() => setPreview(''))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId, chapterId, presetId])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
await ipcCall(() => window.electronAPI.export.copyClipboard(text))
|
||||
showToast(t('export.copied'))
|
||||
}
|
||||
|
||||
const handleSaveTxt = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId || !chapter) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
const defaultName = `${chapter.title}.txt`
|
||||
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
|
||||
if (saved) {
|
||||
showToast(t('export.saved', { path: saved }))
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="exportModal"
|
||||
data-testid="export-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('export.title')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
{t('export.title')}
|
||||
{chapter ? ` — ${chapter.title}` : ''}
|
||||
</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="export-preset-select"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">{t('export.preview')}</label>
|
||||
<pre className="export-preview" data-testid="export-preview">
|
||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-save-txt"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleSaveTxt()}
|
||||
>
|
||||
{t('export.saveTxt')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-copy-clipboard"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t('export.copyClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,12 @@ export function HomePage(): React.JSX.Element {
|
||||
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
|
||||
+ {t('home.newBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-large"
|
||||
data-testid="home-import-book"
|
||||
onClick={() => useAppStore.getState().setImportBookModalOpen(true)}
|
||||
>
|
||||
{t('home.importBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognition
|
||||
@@ -19,24 +18,34 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.inspirationModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setInspirationModalOpen)
|
||||
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||
const books = useBookStore((s) => s.books)
|
||||
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const sortedBooks = useMemo(
|
||||
() => [...books].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()),
|
||||
[books]
|
||||
)
|
||||
|
||||
const [selectedBookId, setSelectedBookId] = useState<string>('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null)
|
||||
const speechAvailable = getSpeechRecognition() !== null
|
||||
|
||||
const effectiveBookId = currentBookId ?? (selectedBookId || sortedBooks[0]?.id || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setTags('')
|
||||
setSelectedBookId(currentBookId ?? sortedBooks[0]?.id ?? '')
|
||||
const timer = setTimeout(() => contentRef.current?.focus(), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
}, [open, currentBookId, sortedBooks])
|
||||
|
||||
const handleVoice = (): void => {
|
||||
const SpeechRecognition = getSpeechRecognition()
|
||||
@@ -51,6 +60,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
const bookId = effectiveBookId
|
||||
if (!bookId || !content.trim()) return
|
||||
const tagList = tags
|
||||
.split(/[,,]/)
|
||||
@@ -64,9 +74,10 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
tagList
|
||||
)
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
setSidebarPanel('inspiration')
|
||||
await switchTarget({ kind: 'inspiration', id: item.id })
|
||||
if (bookId === currentBookId) {
|
||||
addInspirationLocal(item)
|
||||
}
|
||||
showToast(t('inspiration.saved'))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -74,7 +85,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-overlay inspiration-modal--capture"
|
||||
id="inspirationModal"
|
||||
data-testid="inspiration-modal"
|
||||
role="dialog"
|
||||
@@ -89,6 +100,26 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body inspiration-capture">
|
||||
{!currentBookId && sortedBooks.length > 0 && (
|
||||
<>
|
||||
<label className="form-label">{t('inspiration.selectBook')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-book-select"
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
>
|
||||
{sortedBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{!currentBookId && sortedBooks.length === 0 && (
|
||||
<p className="import-hint">{t('inspiration.noBooks')}</p>
|
||||
)}
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-title-input"
|
||||
@@ -132,7 +163,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="inspiration-save-btn"
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || !effectiveBookId}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('inspiration.save')}
|
||||
|
||||
@@ -25,6 +25,9 @@ import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
@@ -117,6 +120,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const documentTitle = useDocumentTitle()
|
||||
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
@@ -148,10 +152,10 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChapterId && (!target || target.kind === 'chapter')) {
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}
|
||||
}, [selectedChapterId, setTarget, target])
|
||||
if (!selectedChapterId) return
|
||||
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}, [selectedChapterId, setTarget, target?.kind, target?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') return
|
||||
@@ -405,6 +409,14 @@ export function EditorLayout(): React.JSX.Element {
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chapter-quick-new"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => setTemplateModalOpen(true)}
|
||||
>
|
||||
+ {t('template.quickNew')}
|
||||
</button>
|
||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
@@ -427,6 +439,16 @@ export function EditorLayout(): React.JSX.Element {
|
||||
) : (
|
||||
<>
|
||||
<div id="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
title={t('export.title')}
|
||||
data-testid="open-export"
|
||||
disabled={!isChapterTarget}
|
||||
onClick={() => useExportStore.getState().openModal()}
|
||||
>
|
||||
📦
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
@@ -572,6 +594,11 @@ export function EditorLayout(): React.JSX.Element {
|
||||
clearBridgeChapter()
|
||||
}}
|
||||
/>
|
||||
<TemplateQuickCreateModal
|
||||
open={templateModalOpen}
|
||||
onClose={() => setTemplateModalOpen(false)}
|
||||
/>
|
||||
<ExportModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ThemeId, Language, PomodoroDuration } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
{ id: 'default', key: 'theme.default' },
|
||||
@@ -18,7 +20,9 @@ const THEMES: { id: ThemeId; key: string }[] = [
|
||||
export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
|
||||
const [section, setSection] = useState<
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
|
||||
>('general')
|
||||
|
||||
return (
|
||||
<div id="settings-page">
|
||||
@@ -40,6 +44,24 @@ export function SettingsPage(): React.JSX.Element {
|
||||
>
|
||||
{t('settings.ai')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'templates' ? 'active' : ''}`}
|
||||
onClick={() => setSection('templates')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-templates"
|
||||
>
|
||||
{t('settings.templates')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'submission' ? 'active' : ''}`}
|
||||
onClick={() => setSection('submission')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-submission"
|
||||
>
|
||||
{t('settings.submission')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
@@ -225,10 +247,12 @@ export function SettingsPage(): React.JSX.Element {
|
||||
</>
|
||||
)}
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'templates' && <TemplateSettingsTab />}
|
||||
{section === 'submission' && <SubmissionPresetTab />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.0.0
|
||||
{t('app.name')} v1.1.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||
import type { GlobalSettings, SubmissionPreset } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
const BUILTIN_ID = 'builtin-webnovel'
|
||||
|
||||
export function SubmissionPresetTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets ?? [])
|
||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId)
|
||||
const update = useSettingsStore((s) => s.update)
|
||||
const allPresets = useMemo(() => mergeSubmissionPresets(customPresets), [customPresets])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const editing = customPresets.find((p) => p.id === editingId) ?? null
|
||||
|
||||
const handleAdd = (): void => {
|
||||
const preset: SubmissionPreset = {
|
||||
id: uuid(),
|
||||
name: t('submission.newPreset'),
|
||||
titleFormat: '# 第{chapterNumber}章 {chapterTitle}',
|
||||
indentParagraphs: true,
|
||||
includeAuthorsNote: true,
|
||||
paragraphGap: 'double'
|
||||
}
|
||||
void update({ submissionPresets: [...customPresets, preset] })
|
||||
setEditingId(preset.id)
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string, patch: Partial<SubmissionPreset>): void => {
|
||||
void update({
|
||||
submissionPresets: customPresets.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (id: string): void => {
|
||||
if (id === BUILTIN_ID) return
|
||||
const next = customPresets.filter((p) => p.id !== id)
|
||||
const patch: Partial<GlobalSettings> = { submissionPresets: next }
|
||||
if (defaultPresetId === id) {
|
||||
Object.assign(patch, { defaultSubmissionPresetId: BUILTIN_ID })
|
||||
}
|
||||
void update(patch)
|
||||
if (editingId === id) setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="settings-submission-presets">
|
||||
<h3 className="settings-subheading">{t('submission.title')}</h3>
|
||||
<div className="template-settings-list">
|
||||
{allPresets.map((preset) => (
|
||||
<div key={preset.id} className="template-settings-item">
|
||||
<div className="template-settings-header">
|
||||
<strong>{preset.name}</strong>
|
||||
{preset.id === BUILTIN_ID && (
|
||||
<span className="template-badge">{t('template.builtin')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="submission-preset-meta">
|
||||
<span>{t('submission.titleFormat')}: {preset.titleFormat}</span>
|
||||
<span>
|
||||
{preset.indentParagraphs ? t('submission.indentOn') : t('submission.indentOff')}
|
||||
</span>
|
||||
</div>
|
||||
{preset.id !== BUILTIN_ID && (
|
||||
<div className="template-settings-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditingId(preset.id)}>
|
||||
{t('dialog.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(preset.id)}
|
||||
>
|
||||
{t('dialog.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="setting-submission-add"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
+ {t('submission.add')}
|
||||
</button>
|
||||
|
||||
{editing && (
|
||||
<div className="template-edit-panel">
|
||||
<label className="form-label">{t('submission.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.name}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
|
||||
/>
|
||||
<label className="form-label">{t('submission.titleFormat')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.titleFormat}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { titleFormat: e.target.value })}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.indentParagraphs}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { indentParagraphs: e.target.checked })}
|
||||
/>
|
||||
{t('submission.indent')}
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.includeAuthorsNote}
|
||||
onChange={(e) =>
|
||||
handleSaveEdit(editing.id, { includeAuthorsNote: e.target.checked })
|
||||
}
|
||||
/>
|
||||
{t('submission.includeAuthorsNote')}
|
||||
</label>
|
||||
<label className="form-label">{t('submission.paragraphGap')}</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={editing.paragraphGap}
|
||||
onChange={(e) =>
|
||||
handleSaveEdit(editing.id, {
|
||||
paragraphGap: e.target.value as SubmissionPreset['paragraphGap']
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="single">{t('submission.gapSingle')}</option>
|
||||
<option value="double">{t('submission.gapDouble')}</option>
|
||||
</select>
|
||||
<label className="form-label" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="defaultPreset"
|
||||
checked={defaultPresetId === editing.id}
|
||||
onChange={() => void update({ defaultSubmissionPresetId: editing.id })}
|
||||
/>{' '}
|
||||
{t('submission.setDefault')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||
import type { ChapterTemplate } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
export function TemplateSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const customTemplates = useSettingsStore((s) => s.chapterTemplates ?? [])
|
||||
const update = useSettingsStore((s) => s.update)
|
||||
const allTemplates = useMemo(() => mergeChapterTemplates(customTemplates), [customTemplates])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const editing = customTemplates.find((tpl) => tpl.id === editingId) ?? null
|
||||
|
||||
const handleAdd = (): void => {
|
||||
const tpl: ChapterTemplate = {
|
||||
id: uuid(),
|
||||
name: t('template.newTemplate'),
|
||||
body: '## 第{chapterNumber}章 {chapterTitle}\n\n'
|
||||
}
|
||||
void update({ chapterTemplates: [...customTemplates, tpl] })
|
||||
setEditingId(tpl.id)
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string, patch: Partial<ChapterTemplate>): void => {
|
||||
void update({
|
||||
chapterTemplates: customTemplates.map((tpl) => (tpl.id === id ? { ...tpl, ...patch } : tpl))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (id: string): void => {
|
||||
void update({ chapterTemplates: customTemplates.filter((tpl) => tpl.id !== id) })
|
||||
if (editingId === id) setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="settings-chapter-templates">
|
||||
<h3 className="settings-subheading">{t('template.title')}</h3>
|
||||
<div className="template-settings-list">
|
||||
{allTemplates.map((tpl) => (
|
||||
<div key={tpl.id} className="template-settings-item">
|
||||
<div className="template-settings-header">
|
||||
<strong>{tpl.name}</strong>
|
||||
{tpl.builtin ? (
|
||||
<span className="template-badge">{t('template.builtin')}</span>
|
||||
) : (
|
||||
<span className="template-badge template-badge--custom">{t('template.custom')}</span>
|
||||
)}
|
||||
</div>
|
||||
{!tpl.builtin && (
|
||||
<div className="template-settings-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditingId(tpl.id)}>
|
||||
{t('dialog.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(tpl.id)}
|
||||
>
|
||||
{t('dialog.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<pre className="template-body-preview">{tpl.body}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="setting-template-add"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
+ {t('template.add')}
|
||||
</button>
|
||||
|
||||
{editing && (
|
||||
<div className="template-edit-panel">
|
||||
<label className="form-label">{t('template.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.name}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
|
||||
/>
|
||||
<label className="form-label">{t('template.body')}</label>
|
||||
<textarea
|
||||
className="form-control form-control--wide"
|
||||
rows={6}
|
||||
value={editing.body}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { body: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface AppState {
|
||||
versionModalOpen: boolean
|
||||
landmarksModalOpen: boolean
|
||||
inspirationModalOpen: boolean
|
||||
importBookModalOpen: boolean
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
@@ -16,6 +17,7 @@ interface AppState {
|
||||
setVersionModalOpen: (open: boolean) => void
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
setImportBookModalOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
versionModalOpen: false,
|
||||
landmarksModalOpen: false,
|
||||
inspirationModalOpen: false,
|
||||
importBookModalOpen: false,
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
@@ -34,6 +37,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface ExportState {
|
||||
open: boolean
|
||||
openModal: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useExportStore = create<ExportState>((set) => ({
|
||||
open: false,
|
||||
openModal: () => set({ open: true }),
|
||||
close: () => set({ open: false })
|
||||
}))
|
||||
@@ -29,6 +29,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
pomodoroDurationMinutes: 25,
|
||||
enableSystemNotifications: false,
|
||||
enableGoalNotifications: true,
|
||||
chapterTemplates: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -1987,3 +1987,144 @@
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.template-card.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.template-badge {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.template-badge--custom {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.template-settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-settings-item {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.template-body-preview {
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.template-edit-panel {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-subheading {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-file-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-file-row .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.import-preview-list {
|
||||
font-size: 12px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.import-progress {
|
||||
margin-top: 12px;
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.import-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.import-progress span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-preview {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.inspiration-modal--capture {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.submission-preset-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user