feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0
交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import './i18n'
|
||||
import './styles/themes.css'
|
||||
import './styles/globals.css'
|
||||
import './styles/layout.css'
|
||||
import { TopBar } from '@renderer/components/layout/TopBar'
|
||||
import { HomePage } from '@renderer/components/home/HomePage'
|
||||
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
||||
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
||||
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const view = useAppStore((s) => s.view)
|
||||
const toast = useAppStore((s) => s.toast)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
||||
const loadBooks = useBookStore((s) => s.loadBooks)
|
||||
const { activeTabId, openTabs } = useTabStore()
|
||||
const openBook = useBookStore((s) => s.openBook)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await loadSettings()
|
||||
await loadBooks()
|
||||
})()
|
||||
}, [loadSettings, loadBooks])
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
||||
}, [loaded, onboardingCompleted])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
||||
if (action === 'saveChapter') {
|
||||
try {
|
||||
await flushEditorSave()
|
||||
} catch {
|
||||
showToast(t('toast.saveFailed'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action === 'newChapter') {
|
||||
if (view !== 'editor') return
|
||||
const { currentBookId, activeVolumeId, refreshChapters, setSelectedChapter } = useBookStore.getState()
|
||||
if (!currentBookId || !activeVolumeId) return
|
||||
await flushEditorSave()
|
||||
const ch = await ipcCall(() =>
|
||||
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(ch.id)
|
||||
return
|
||||
}
|
||||
if (action === 'closeTab') {
|
||||
const tab = openTabs.find((x) => x.id === activeTabId)
|
||||
if (!tab) return
|
||||
const next = useTabStore.getState().closeTab(tab.id)
|
||||
if (next) {
|
||||
const nt = useTabStore.getState().openTabs.find((x) => x.id === next)
|
||||
if (nt?.type === 'book' && nt.bookId) {
|
||||
await openBook(nt.bookId)
|
||||
setView('editor')
|
||||
} else setView('settings')
|
||||
} else setView('home')
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
||||
|
||||
const handleOnboardingComplete = (): void => {
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<TopBar />
|
||||
<div id="main-area">
|
||||
{view === 'home' && <HomePage />}
|
||||
{view === 'editor' && <EditorLayout />}
|
||||
{view === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
return (
|
||||
<JotaiProvider>
|
||||
<AppInner />
|
||||
</JotaiProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const editorDirtyAtom = atom(false)
|
||||
export const editorSavingAtom = atom(false)
|
||||
export const lastSavedAtAtom = atom<Date | null>(null)
|
||||
export const wordCountAtom = atom({ chapter: 0, volume: 0, book: 0 })
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import type { Chapter } from '@shared/types'
|
||||
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function countPlain(text: string): number {
|
||||
const stripped = text.replace(/\s+/g, '')
|
||||
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||
}
|
||||
|
||||
interface TipTapEditorProps {
|
||||
chapter: Chapter | null
|
||||
bookId: string | null
|
||||
}
|
||||
|
||||
export function TipTapEditor({ chapter, bookId }: TipTapEditorProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation()
|
||||
const setDirty = useSetAtom(editorDirtyAtom)
|
||||
const setSaving = useSetAtom(editorSavingAtom)
|
||||
const setLastSaved = useSetAtom(lastSavedAtAtom)
|
||||
const setWordCount = useSetAtom(wordCountAtom)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const chapterIdRef = useRef<string | null>(null)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||
],
|
||||
content: chapter?.content ?? '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
setDirty(true)
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave?.()
|
||||
}, 2000)
|
||||
const text = ed.getText()
|
||||
const volId = chapter?.volumeId
|
||||
const chapterCount = countPlain(text)
|
||||
const volumeTotal = chapters
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0)
|
||||
const bookTotal = chapters.reduce(
|
||||
(sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount),
|
||||
0
|
||||
)
|
||||
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
|
||||
},
|
||||
editorProps: {
|
||||
handlePaste: (view, event) => {
|
||||
if (event.shiftKey) return false
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (text) {
|
||||
view.dispatch(view.state.tr.insertText(text))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}, [i18n.language])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!bookId || !chapter || !editor) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const html = editor.getHTML()
|
||||
const text = editor.getText()
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId,
|
||||
chapterId: chapter.id,
|
||||
content: html,
|
||||
cursorOffset: editor.state.selection.anchor
|
||||
})
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
setDirty(false)
|
||||
setLastSaved(new Date())
|
||||
const volId = updated.volumeId
|
||||
const volumeTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
const bookTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal])
|
||||
|
||||
useEffect(() => {
|
||||
;(window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave = save
|
||||
return () => {
|
||||
clearTimeout(timerRef.current)
|
||||
delete (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||
}
|
||||
}, [save])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !chapter) return
|
||||
if (chapterIdRef.current !== chapter.id) {
|
||||
void (async () => {
|
||||
await flushEditorSave()
|
||||
chapterIdRef.current = chapter.id
|
||||
editor.commands.setContent(chapter.content || '')
|
||||
const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||
editor.commands.focus(pos)
|
||||
setDirty(false)
|
||||
setWordCount({
|
||||
chapter: chapter.wordCount,
|
||||
volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0),
|
||||
book: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||
})
|
||||
})()
|
||||
}
|
||||
}, [chapter, editor, chapters, setDirty, setWordCount])
|
||||
|
||||
if (!chapter) {
|
||||
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-content-wrap">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function flushEditorSave(): Promise<void> {
|
||||
const fn = (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||
if (fn) await fn()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
|
||||
export function HomePage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { books, loadBooks, openBook } = useBookStore()
|
||||
const { openBookTab, openSettingsTab } = useTabStore()
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState('玄幻')
|
||||
const [target, setTarget] = useState('')
|
||||
|
||||
const handleOpenBook = async (bookId: string, bookName: string): Promise<void> => {
|
||||
await openBook(bookId)
|
||||
openBookTab(bookId, bookName)
|
||||
setView('editor')
|
||||
}
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!name.trim()) {
|
||||
showToast(t('toast.enterBookName'))
|
||||
return
|
||||
}
|
||||
const meta = await ipcCall(() =>
|
||||
window.electronAPI.book.create({
|
||||
name: name.trim(),
|
||||
category,
|
||||
targetWordCount: target ? Number(target) : null
|
||||
})
|
||||
)
|
||||
setShowDialog(false)
|
||||
setName('')
|
||||
await loadBooks()
|
||||
showToast(t('toast.bookCreated'))
|
||||
await handleOpenBook(meta.id, meta.name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="home-page">
|
||||
<div className="home-hero">
|
||||
<h1>✒ {t('app.name')}</h1>
|
||||
<p className="subtitle">{t('app.tagline')}</p>
|
||||
</div>
|
||||
<div className="home-actions">
|
||||
<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'))}>
|
||||
{t('home.importBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
{t('home.exportAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-large"
|
||||
onClick={() => {
|
||||
openSettingsTab()
|
||||
setView('settings')
|
||||
}}
|
||||
>
|
||||
⚙ {t('settings.title')}
|
||||
</button>
|
||||
</div>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 14 }}>
|
||||
📚 {t('home.myBooks')}{' '}
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{t('home.bookCount', { count: books.length })}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="book-grid">
|
||||
{books.map((book) => (
|
||||
<div
|
||||
key={book.id}
|
||||
className="book-card"
|
||||
onClick={() => void handleOpenBook(book.id, book.name)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleOpenBook(book.id, book.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="book-name">{book.name}</div>
|
||||
<div className="book-meta">
|
||||
<div>{book.category}</div>
|
||||
<div>{new Date(book.lastOpenedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="book-card"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)' }}
|
||||
onClick={() => setShowDialog(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
+ {t('home.newBook')}
|
||||
</div>
|
||||
</div>
|
||||
{showDialog && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog-content">
|
||||
<h3 style={{ marginBottom: 16 }}>{t('dialog.newBook.title')}</h3>
|
||||
<div className="form-group">
|
||||
<label>{t('dialog.newBook.name')}</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('dialog.newBook.category')}</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="玄幻">玄幻</option>
|
||||
<option value="仙侠">仙侠</option>
|
||||
<option value="科幻">科幻</option>
|
||||
<option value="言情">言情</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('dialog.newBook.target')}</label>
|
||||
<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
|
||||
{t('dialog.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { RightPanel } from '@renderer/components/layout/RightPanel'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
lastSavedAtAtom,
|
||||
wordCountAtom
|
||||
} from '@renderer/atoms/editorAtoms'
|
||||
|
||||
export function EditorLayout(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const activePanel = useAppStore((s) => s.sidebarPanel)
|
||||
const {
|
||||
currentBookId,
|
||||
volumes,
|
||||
chapters,
|
||||
selectedChapterId,
|
||||
activeVolumeId,
|
||||
setSelectedChapter,
|
||||
setActiveVolume,
|
||||
refreshChapters
|
||||
} = useBookStore()
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||
const wordCount = useAtomValue(wordCountAtom)
|
||||
|
||||
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
|
||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||
await flushEditorSave()
|
||||
setSelectedChapter(chapterId)
|
||||
}
|
||||
|
||||
const handleNewChapter = async (): Promise<void> => {
|
||||
if (!currentBookId || !activeVolumeId) return
|
||||
await flushEditorSave()
|
||||
const ch = await ipcCall(() =>
|
||||
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(ch.id)
|
||||
}
|
||||
|
||||
const handleNewVolume = async (): Promise<void> => {
|
||||
if (!currentBookId) return
|
||||
const vol = await ipcCall(() =>
|
||||
window.electronAPI.volume.create(currentBookId, t('editor.newVolume'))
|
||||
)
|
||||
await refreshChapters()
|
||||
setActiveVolume(vol.id)
|
||||
}
|
||||
|
||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
|
||||
return (
|
||||
<div id="editor-layout">
|
||||
<div id="left-sidebar">
|
||||
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
||||
<div className="sidebar-nav">
|
||||
{(['chapters', 'outline', 'setting', 'inspiration'] as const).map((panel) => (
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||
onClick={() => sidebarPanel(panel)}
|
||||
>
|
||||
{panel === 'chapters' && t('sidebar.chapters')}
|
||||
{panel === 'outline' && t('sidebar.outline')}
|
||||
{panel === 'setting' && t('sidebar.settings')}
|
||||
{panel === 'inspiration' && t('sidebar.inspiration')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={`sidebar-panel ${activePanel === 'chapters' ? 'active' : ''}`}>
|
||||
{volumes.map((vol) => (
|
||||
<div key={vol.id}>
|
||||
<div
|
||||
className="vol-header"
|
||||
onClick={() => setActiveVolume(vol.id)}
|
||||
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
|
||||
>
|
||||
{vol.name}
|
||||
</div>
|
||||
{chapters
|
||||
.filter((c) => c.volumeId === vol.id)
|
||||
.map((ch, idx) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
|
||||
onClick={() => void handleSelectChapter(ch.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>{idx + 1}.</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
||||
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activePanel !== 'chapters' && (
|
||||
<div className="sidebar-panel active">
|
||||
<div className="placeholder-box" style={{ padding: 20 }}>
|
||||
{t('feature.comingSoon')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-area">
|
||||
<div id="editor-toolbar">
|
||||
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
|
||||
📎
|
||||
</button>
|
||||
<span className="editor-label">
|
||||
{currentChapter ? currentChapter.title : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<RightPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
|
||||
export function RightPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const { rightPanel, setRightPanel } = useAppStore()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'ai' as const, label: t('panel.ai') },
|
||||
{ id: 'knowledge' as const, label: t('panel.knowledge') },
|
||||
{ id: 'wordfreq' as const, label: t('panel.wordfreq') },
|
||||
{ id: 'book-settings' as const, label: t('panel.bookSettings') }
|
||||
]
|
||||
|
||||
return (
|
||||
<div id="right-panel">
|
||||
<div className="panel-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setRightPanel(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
||||
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
||||
</div>
|
||||
{(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => (
|
||||
<div key={id} className={`panel-content ${rightPanel === id ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
|
||||
export function TopBar(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||
|
||||
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
const next = closeTab(tabId)
|
||||
if (next) {
|
||||
const tab = useTabStore.getState().openTabs.find((t) => t.id === next)
|
||||
if (tab?.type === 'book' && tab.bookId) {
|
||||
void useBookStore.getState().openBook(tab.bookId)
|
||||
setView('editor')
|
||||
} else if (tab?.type === 'settings') {
|
||||
setView('settings')
|
||||
}
|
||||
} else {
|
||||
setView('home')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="topbar">
|
||||
<button type="button" className="logo-area" onClick={() => setView('home')}>
|
||||
✒ {t('app.name')}
|
||||
</button>
|
||||
<div className="tab-bar">
|
||||
{openTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`tab ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
switchTab(tab.id)
|
||||
if (tab.type === 'book' && tab.bookId) {
|
||||
void useBookStore.getState().openBook(tab.bookId)
|
||||
setView('editor')
|
||||
} else {
|
||||
setView('settings')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{tab.name}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||
style={{ marginLeft: 4, opacity: 0.6, fontSize: 10 }}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="top-spacer" />
|
||||
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
📊
|
||||
</button>
|
||||
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
||||
⚙
|
||||
</button>
|
||||
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
👁
|
||||
</button>
|
||||
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}>—</button>
|
||||
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.maximize()}>□</button>
|
||||
<button type="button" className="top-btn window-close" onClick={() => window.electronAPI.window.close()}>✕</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const update = useSettingsStore((s) => s.update)
|
||||
const loadBooks = useBookStore((s) => s.loadBooks)
|
||||
const [step, setStep] = useState(0)
|
||||
const [penName, setPenName] = useState('')
|
||||
const [createSample, setCreateSample] = useState(true)
|
||||
|
||||
const finish = async (skipped = false): Promise<void> => {
|
||||
const name = skipped ? '未命名作者' : penName.trim() || '未命名作者'
|
||||
await update({ penName: name, onboardingCompleted: true })
|
||||
if (createSample) {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.book.create({
|
||||
name: t('book.createSample'),
|
||||
category: '未分类',
|
||||
createSampleChapter: true
|
||||
})
|
||||
)
|
||||
await loadBooks()
|
||||
}
|
||||
onComplete()
|
||||
}
|
||||
|
||||
const slides = [
|
||||
<div key="0" style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 12 }}>✒</div>
|
||||
<h2>{t('onboarding.welcome')}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.tagline')}</p>
|
||||
</div>,
|
||||
<div key="1">
|
||||
<h2>{t('onboarding.slide1')}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide1desc')}</p>
|
||||
</div>,
|
||||
<div key="2">
|
||||
<h2>{t('onboarding.slide2')}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide2desc')}</p>
|
||||
</div>,
|
||||
<div key="3">
|
||||
<h2>{t('onboarding.slide3')}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide3desc')}</p>
|
||||
</div>,
|
||||
<div key="4">
|
||||
<h2>{t('onboarding.penName')}</h2>
|
||||
<input
|
||||
data-testid="onboarding-pen-name"
|
||||
value={penName}
|
||||
onChange={(e) => setPenName(e.target.value)}
|
||||
placeholder={t('onboarding.penNamePlaceholder')}
|
||||
style={{ width: '100%', marginTop: 12, padding: 10, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-tertiary)', color: 'inherit' }}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
|
||||
{t('onboarding.createSample')}
|
||||
</label>
|
||||
</div>
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog-content" style={{ maxWidth: 520 }}>
|
||||
{slides[step]}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
|
||||
<button type="button" className="btn" onClick={() => void finish(true)}>
|
||||
{t('onboarding.skip')}
|
||||
</button>
|
||||
{step < slides.length - 1 ? (
|
||||
<button type="button" className="btn primary" data-testid="onboarding-next" onClick={() => setStep((s) => s + 1)}>
|
||||
{t('onboarding.next')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn primary" data-testid="onboarding-start" onClick={() => void finish(false)}>
|
||||
{t('onboarding.start')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ThemeId, Language } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
{ id: 'default', key: 'theme.default' },
|
||||
{ id: 'bamboo', key: 'theme.bamboo' },
|
||||
{ id: 'moonlit', key: 'theme.moonlit' },
|
||||
{ id: 'ricepaper', key: 'theme.ricepaper' },
|
||||
{ id: 'mist', key: 'theme.mist' },
|
||||
{ id: 'teagarden', key: 'theme.teagarden' },
|
||||
{ id: 'twilight', key: 'theme.twilight' }
|
||||
]
|
||||
|
||||
export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
|
||||
|
||||
return (
|
||||
<div id="settings-page">
|
||||
<div className="settings-sidebar">
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setSection('general')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{t('settings.general')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{t('settings.shortcuts')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'about' ? 'active' : ''}`}
|
||||
onClick={() => setSection('about')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{t('settings.about')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
<h2 style={{ marginBottom: 20 }}>{t('settings.title')}</h2>
|
||||
{section === 'general' && (
|
||||
<>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.penName')}</span>
|
||||
<input
|
||||
data-testid="settings-pen-name"
|
||||
value={settings.penName}
|
||||
onChange={(e) => void settings.update({ penName: e.target.value })}
|
||||
style={{ width: 160, padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.theme')}</span>
|
||||
<select
|
||||
data-testid="settings-theme"
|
||||
value={settings.theme}
|
||||
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
|
||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{t(th.key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.language')}</span>
|
||||
<select
|
||||
data-testid="settings-language"
|
||||
value={settings.language}
|
||||
onChange={(e) => void settings.update({ language: e.target.value as Language })}
|
||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.1.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ActionId } from '@shared/types'
|
||||
import { DEFAULT_SHORTCUTS } from '@shared/default-shortcuts'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
|
||||
const ACTION_IDS: ActionId[] = [
|
||||
'saveChapter',
|
||||
'newChapter',
|
||||
'focusMode',
|
||||
'globalSearch',
|
||||
'closeTab',
|
||||
'openReference',
|
||||
'insertLandmark',
|
||||
'openLandmarks',
|
||||
'exportBook',
|
||||
'toggleTTS',
|
||||
'captureInspiration'
|
||||
]
|
||||
|
||||
function formatAccelerator(acc: string): string {
|
||||
return acc.replace(/CommandOrControl/g, 'Ctrl')
|
||||
}
|
||||
|
||||
function keyEventToAccelerator(e: KeyboardEvent): string | null {
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return null
|
||||
|
||||
const parts: string[] = []
|
||||
if (e.ctrlKey || e.metaKey) parts.push('CommandOrControl')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
|
||||
const special: Record<string, string> = {
|
||||
' ': 'Space',
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Escape: 'Esc',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
Enter: 'Enter',
|
||||
Tab: 'Tab'
|
||||
}
|
||||
|
||||
let key = special[e.key]
|
||||
if (!key) {
|
||||
if (e.key.length === 1) key = e.key.toUpperCase()
|
||||
else if (e.code.startsWith('Key')) key = e.code.slice(3)
|
||||
else if (e.code.startsWith('Digit')) key = e.code.slice(5)
|
||||
else key = e.key
|
||||
}
|
||||
|
||||
parts.push(key)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
export function ShortcutEditor(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const [bindings, setBindings] = useState<Record<ActionId, string>>({ ...DEFAULT_SHORTCUTS })
|
||||
const [recording, setRecording] = useState<ActionId | null>(null)
|
||||
|
||||
const loadBindings = useCallback(async () => {
|
||||
const all = await ipcCall(() => window.electronAPI.shortcut.getAll())
|
||||
setBindings(all)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadBindings()
|
||||
}, [loadBindings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape') {
|
||||
setRecording(null)
|
||||
return
|
||||
}
|
||||
|
||||
const accelerator = keyEventToAccelerator(e)
|
||||
if (!accelerator) return
|
||||
|
||||
const conflict = ACTION_IDS.some(
|
||||
(id) => id !== recording && bindings[id] === accelerator
|
||||
)
|
||||
if (conflict) {
|
||||
showToast(t('shortcuts.conflict'))
|
||||
setRecording(null)
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.shortcut.register(recording, accelerator))
|
||||
setBindings((prev) => ({ ...prev, [recording]: accelerator }))
|
||||
} catch {
|
||||
showToast(t('shortcuts.registerFailed'))
|
||||
} finally {
|
||||
setRecording(null)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [recording, bindings, showToast, t])
|
||||
|
||||
return (
|
||||
<div className="shortcut-editor">
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, fontSize: 13 }}>
|
||||
{recording ? t('shortcuts.recordHint') : t('shortcuts.clickToRecord')}
|
||||
</p>
|
||||
{ACTION_IDS.map((action) => (
|
||||
<div key={action} className="setting-item">
|
||||
<span>{t(`shortcuts.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`shortcut-key ${recording === action ? 'recording' : ''}`}
|
||||
data-testid={`shortcut-${action}`}
|
||||
onClick={() => setRecording(action)}
|
||||
>
|
||||
{recording === action ? t('shortcuts.recording') : formatAccelerator(bindings[action] ?? '—')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import zh from '../../../public/locales/zh-CN/translation.json'
|
||||
import en from '../../../public/locales/en/translation.json'
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zh },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: 'zh-CN',
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: { escapeValue: false }
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>笔临</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IpcError, IpcResult } from '@shared/types'
|
||||
|
||||
export class IpcCallError extends Error {
|
||||
ipcError: IpcError
|
||||
|
||||
constructor(ipcError: IpcError) {
|
||||
super(ipcError.message)
|
||||
this.name = 'IpcCallError'
|
||||
this.ipcError = ipcError
|
||||
}
|
||||
}
|
||||
|
||||
export async function ipcCall<T>(fn: () => Promise<IpcResult<T>>): Promise<T> {
|
||||
const result = await fn()
|
||||
if (!result.ok) throw new IpcCallError(result.error)
|
||||
return result.data
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ThemeId } from '@shared/types'
|
||||
|
||||
export function applyTheme(theme: ThemeId): void {
|
||||
if (theme === 'default') {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type AppView = 'home' | 'editor' | 'settings'
|
||||
|
||||
interface AppState {
|
||||
view: AppView
|
||||
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
|
||||
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
setRightPanel: (panel: AppState['rightPanel']) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
view: 'home',
|
||||
sidebarPanel: 'chapters',
|
||||
rightPanel: 'ai',
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
setRightPanel: (rightPanel) => set({ rightPanel }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
},
|
||||
clearToast: () => set({ toast: null })
|
||||
}))
|
||||
@@ -0,0 +1,66 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookMeta, Volume, Chapter } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface BookState {
|
||||
books: BookMeta[]
|
||||
currentBookId: string | null
|
||||
volumes: Volume[]
|
||||
chapters: Chapter[]
|
||||
selectedChapterId: string | null
|
||||
activeVolumeId: string | null
|
||||
loadBooks: () => Promise<void>
|
||||
openBook: (bookId: string) => Promise<void>
|
||||
setSelectedChapter: (chapterId: string) => void
|
||||
setActiveVolume: (volumeId: string) => void
|
||||
refreshChapters: () => Promise<void>
|
||||
updateChapterLocal: (chapter: Chapter) => void
|
||||
}
|
||||
|
||||
export const useBookStore = create<BookState>((set, get) => ({
|
||||
books: [],
|
||||
currentBookId: null,
|
||||
volumes: [],
|
||||
chapters: [],
|
||||
selectedChapterId: null,
|
||||
activeVolumeId: null,
|
||||
loadBooks: async () => {
|
||||
const books = await ipcCall(() => window.electronAPI.book.list())
|
||||
set({ books })
|
||||
},
|
||||
openBook: async (bookId) => {
|
||||
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||
const firstVolume = result.volumes[0]?.id ?? null
|
||||
const lastChapter =
|
||||
result.meta.lastChapterId ??
|
||||
result.chapters.find((c) => c.volumeId === firstVolume)?.id ??
|
||||
result.chapters[0]?.id ??
|
||||
null
|
||||
set({
|
||||
currentBookId: bookId,
|
||||
volumes: result.volumes,
|
||||
chapters: result.chapters,
|
||||
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||
selectedChapterId: lastChapter
|
||||
})
|
||||
},
|
||||
setSelectedChapter: (chapterId) => {
|
||||
const ch = get().chapters.find((c) => c.id === chapterId)
|
||||
set({
|
||||
selectedChapterId: chapterId,
|
||||
activeVolumeId: ch?.volumeId ?? get().activeVolumeId
|
||||
})
|
||||
},
|
||||
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
|
||||
refreshChapters: async () => {
|
||||
const bookId = get().currentBookId
|
||||
if (!bookId) return
|
||||
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||
set({ volumes: result.volumes, chapters: result.chapters })
|
||||
},
|
||||
updateChapterLocal: (chapter) => {
|
||||
set((s) => ({
|
||||
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
|
||||
}))
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { applyTheme } from '@renderer/lib/theme'
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
interface SettingsState extends GlobalSettings {
|
||||
loaded: boolean
|
||||
load: () => Promise<void>
|
||||
update: (partial: Partial<GlobalSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
penName: '未命名作者',
|
||||
onboardingCompleted: false,
|
||||
theme: 'default',
|
||||
language: 'zh-CN',
|
||||
dailyWordGoal: 0,
|
||||
shortcuts: {} as Record<ActionId, string>,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
set({ ...data, loaded: true })
|
||||
applyTheme(data.theme)
|
||||
await i18n.changeLanguage(data.language)
|
||||
},
|
||||
update: async (partial) => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.update(partial))
|
||||
set({ ...get(), ...data })
|
||||
if (partial.theme) applyTheme(data.theme)
|
||||
if (partial.language) await i18n.changeLanguage(data.language)
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface AppTab {
|
||||
id: string
|
||||
type: 'book' | 'settings'
|
||||
name: string
|
||||
bookId?: string
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
openTabs: AppTab[]
|
||||
activeTabId: string | null
|
||||
openBookTab: (bookId: string, name: string) => void
|
||||
openSettingsTab: () => void
|
||||
switchTab: (tabId: string) => void
|
||||
closeTab: (tabId: string) => string | null
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
openBookTab: (bookId, name) => {
|
||||
const existing = get().openTabs.find((t) => t.type === 'book' && t.bookId === bookId)
|
||||
if (existing) {
|
||||
set({ activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
const id = `book-${bookId}`
|
||||
set((s) => ({
|
||||
openTabs: [...s.openTabs, { id, type: 'book', name, bookId }],
|
||||
activeTabId: id
|
||||
}))
|
||||
},
|
||||
openSettingsTab: () => {
|
||||
const existing = get().openTabs.find((t) => t.type === 'settings')
|
||||
if (existing) {
|
||||
set({ activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
openTabs: [...s.openTabs, { id: 'settings', type: 'settings', name: '系统设置' }],
|
||||
activeTabId: 'settings'
|
||||
}))
|
||||
},
|
||||
switchTab: (tabId) => set({ activeTabId: tabId }),
|
||||
closeTab: (tabId) => {
|
||||
const { openTabs, activeTabId } = get()
|
||||
const idx = openTabs.findIndex((t) => t.id === tabId)
|
||||
if (idx === -1) return activeTabId
|
||||
const nextTabs = openTabs.filter((t) => t.id !== tabId)
|
||||
let nextActive = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
nextActive = nextTabs[Math.max(0, idx - 1)]?.id ?? null
|
||||
}
|
||||
set({ openTabs: nextTabs, activeTabId: nextActive })
|
||||
return nextActive
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,64 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 10px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
#topbar button,
|
||||
#topbar .tab,
|
||||
#topbar .logo-area {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo-area:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.top-spacer {
|
||||
flex: 1;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.top-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.top-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.window-close:hover {
|
||||
background: var(--red) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
#main-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#home-page {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.home-hero h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home-hero .subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.home-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-large.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.book-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.book-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#editor-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#left-sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
color: var(--accent-light);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vol-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px 7px 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chapter-item:hover,
|
||||
.chapter-item.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ch-badge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ch-badge.done {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.ch-badge.draft {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-footer button {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#editor-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-btn:hover,
|
||||
.tool-btn.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor-content-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--editor-bg);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
min-height: 100%;
|
||||
padding: 32px 48px;
|
||||
outline: none;
|
||||
color: var(--editor-text);
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--text-muted);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
#right-panel {
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.panel-tab.active {
|
||||
color: var(--accent-light);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-content.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#settings-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 180px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.settings-nav-item.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
min-width: 140px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shortcut-key.recording {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
width: min(480px, 90vw);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 10px 18px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.ai-input-disabled {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
:root {
|
||||
--bg-primary: #1c1816;
|
||||
--bg-secondary: #201c1a;
|
||||
--bg-tertiary: #262220;
|
||||
--bg-surface: #2a2624;
|
||||
--bg-hover: #302c28;
|
||||
--bg-active: #363230;
|
||||
--bg-card: #221e1c;
|
||||
--text-primary: #e8e0d8;
|
||||
--text-secondary: #b8aca0;
|
||||
--text-muted: #786858;
|
||||
--text-dim: #584838;
|
||||
--accent: #c0504a;
|
||||
--accent-glow: rgba(192, 80, 74, 0.25);
|
||||
--accent-light: #d4706a;
|
||||
--accent-dark: #a0403a;
|
||||
--green: #609060;
|
||||
--red: #c0504a;
|
||||
--orange: #c08050;
|
||||
--border: #383028;
|
||||
--border-light: #484030;
|
||||
--editor-bg: #1c1816;
|
||||
--editor-text: #e8e0d8;
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-theme='bamboo'] {
|
||||
--bg-primary: #1a1e18;
|
||||
--bg-secondary: #1e221c;
|
||||
--bg-tertiary: #242820;
|
||||
--bg-surface: #282c24;
|
||||
--bg-hover: #2e322a;
|
||||
--bg-card: #20241c;
|
||||
--text-primary: #e0e4d8;
|
||||
--text-secondary: #b0b8a8;
|
||||
--text-muted: #687858;
|
||||
--accent: #7a9a60;
|
||||
--accent-light: #98b878;
|
||||
--border: #303828;
|
||||
--editor-bg: #1a1e18;
|
||||
--editor-text: #e0e4d8;
|
||||
}
|
||||
|
||||
[data-theme='moonlit'] {
|
||||
--bg-primary: #1a1e24;
|
||||
--bg-secondary: #1e2228;
|
||||
--bg-tertiary: #242830;
|
||||
--bg-hover: #2e3238;
|
||||
--bg-card: #202428;
|
||||
--text-primary: #d8dce4;
|
||||
--text-secondary: #a8b0b8;
|
||||
--accent: #8098b8;
|
||||
--accent-light: #a0b4d0;
|
||||
--border: #2a3038;
|
||||
--editor-bg: #1a1e24;
|
||||
--editor-text: #d8dce4;
|
||||
}
|
||||
|
||||
[data-theme='ricepaper'] {
|
||||
--bg-primary: #f4efe4;
|
||||
--bg-secondary: #faf6ee;
|
||||
--bg-tertiary: #efe8d8;
|
||||
--bg-hover: #e8e0d0;
|
||||
--bg-card: #fcf6ec;
|
||||
--text-primary: #3a3028;
|
||||
--text-secondary: #6a5a48;
|
||||
--text-muted: #988878;
|
||||
--accent: #8b4513;
|
||||
--accent-light: #a86030;
|
||||
--border: #d8d0c0;
|
||||
--editor-bg: #fdf9f2;
|
||||
--editor-text: #3a3028;
|
||||
}
|
||||
|
||||
[data-theme='mist'] {
|
||||
--bg-primary: #eff0f2;
|
||||
--bg-secondary: #f5f6f8;
|
||||
--bg-tertiary: #eaeaee;
|
||||
--bg-hover: #e2e3e8;
|
||||
--bg-card: #f8f8fa;
|
||||
--text-primary: #2c3038;
|
||||
--text-secondary: #585c68;
|
||||
--accent: #6878a0;
|
||||
--border: #d8dae0;
|
||||
--editor-bg: #f8f9fc;
|
||||
--editor-text: #2c3038;
|
||||
}
|
||||
|
||||
[data-theme='teagarden'] {
|
||||
--bg-primary: #eef0e4;
|
||||
--bg-secondary: #f4f6ec;
|
||||
--bg-tertiary: #e6e8d8;
|
||||
--bg-hover: #dee2d0;
|
||||
--bg-card: #f2f4e8;
|
||||
--text-primary: #2d3a28;
|
||||
--text-secondary: #4a5840;
|
||||
--accent: #688848;
|
||||
--border: #d0d8c0;
|
||||
--editor-bg: #f6faf0;
|
||||
--editor-text: #2d3a28;
|
||||
}
|
||||
|
||||
[data-theme='twilight'] {
|
||||
--bg-primary: #1f1a16;
|
||||
--bg-secondary: #241e1a;
|
||||
--bg-tertiary: #2a2420;
|
||||
--bg-hover: #342e28;
|
||||
--bg-card: #26201c;
|
||||
--text-primary: #e8dcc8;
|
||||
--text-secondary: #b8a890;
|
||||
--accent: #c88850;
|
||||
--accent-light: #d8a870;
|
||||
--border: #383028;
|
||||
--editor-bg: #1f1a16;
|
||||
--editor-text: #e8dcc8;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="./../shared/electron-api.d.ts" />
|
||||
Reference in New Issue
Block a user