= ({
{!isHeaderCompact && } + = ({ ids, settings }) => { @@ -50,6 +52,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i const { getConfig, getBookData, saveConfig } = useBookDataStore(); const { getView, setBookKeys, getViewSettings } = useReaderStore(); const { initViewState, getViewState, clearViewState } = useReaderStore(); + const clearReviewBook = useReviewModeStore((state) => state.clearBook); const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore(); const [showDetailsBook, setShowDetailsBook] = useState(null); const [shareDialogState, setShareDialogState] = useState<{ @@ -176,6 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => { console.log('Closing book', bookKey); + clearReviewBook(bookKey); const viewState = getViewState(bookKey); if (viewState?.isPrimary && appService?.isDesktopApp) { @@ -291,6 +295,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i onGoToLibrary={handleCloseBooksToLibrary} /> {isSettingsDialogOpen && } + {showDetailsBook && ( + DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions); + +const emptyReviewRows: ReviewRow[] = []; + +const normalizeFile = (value?: string) => + (() => { + const raw = (value || '').split('#')[0] || ''; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + })() + .replaceAll('\\', '/') + .replace(/^\/+/, '') + .toLowerCase(); + +const sameFile = (rowFile: string, sectionHref?: string) => { + const row = normalizeFile(rowFile); + const href = normalizeFile(sectionHref); + if (!row || !href) return false; + return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`); +}; + +const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => { + const section = bookDoc.sections[index ?? -1]; + return section?.href || section?.id; +}; + +const clearReviewMarks = (doc: Document) => { + doc.getElementById(STYLE_ID)?.remove(); + doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => { + const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR); + if (originalHtml !== null) { + element.innerHTML = originalHtml; + } + element.removeAttribute(ROW_ATTR); + element.removeAttribute(ROLE_ATTR); + element.removeAttribute(ORIGINAL_HTML_ATTR); + element.classList.remove( + 'readest-review-source', + 'readest-review-target', + 'readest-review-active', + ); + }); +}; + +const ensureReviewStyle = (doc: Document) => { + if (doc.getElementById(STYLE_ID)) return; + const style = doc.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + [${ROW_ATTR}] { + cursor: pointer; + user-select: text; + outline-offset: 0.18em; + transition: background-color 120ms ease, outline-color 120ms ease; + } + .readest-review-source { + background: rgba(127, 127, 127, 0.12); + outline: 1px dashed rgba(127, 127, 127, 0.64); + } + .readest-review-target { + background: rgba(22, 163, 74, 0.14); + outline: 1px solid rgba(22, 163, 74, 0.72); + } + .readest-review-active { + background: rgba(37, 99, 235, 0.18) !important; + outline: 2px solid currentColor !important; + } + `; + doc.head.appendChild(style); +}; + +const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => { + const element = target as Element | null; + if (!element || typeof element.closest !== 'function') return null; + if (element.closest('a, button, input, textarea, select, video, audio')) return null; + return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null; +}; + +const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => { + const element = + node?.nodeType === Node.ELEMENT_NODE + ? (node as Element) + : (node?.parentElement as Element | null); + if (!element || typeof element.closest !== 'function') return null; + return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null; +}; + +const selectedReviewTarget = (doc: Document): HTMLElement | null => { + const selection = doc.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; + const range = selection.getRangeAt(0); + return ( + closestReviewTargetFromNode(range.commonAncestorContainer) || + closestReviewTargetFromNode(range.startContainer) || + closestReviewTargetFromNode(range.endContainer) + ); +}; + +const markParagraph = ( + element: Element | undefined, + row: ReviewRow, + role: 'source' | 'target', + active: boolean, +) => { + const htmlElement = element as HTMLElement | undefined; + if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return; + htmlElement.setAttribute(ROW_ATTR, row.id); + htmlElement.setAttribute(ROLE_ATTR, role); + htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target'); + htmlElement.classList.toggle('readest-review-active', active); + if (role === 'target' && row.current_html) { + if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) { + htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML); + } + htmlElement.innerHTML = sanitizeReviewHtml(row.current_html); + } +}; + +const applyActiveReviewMark = (doc: Document, selectedRowId: string) => { + doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => { + element.classList.toggle( + 'readest-review-active', + element.getAttribute(ROW_ATTR) === selectedRowId, + ); + }); +}; + +const applyReviewMarks = ( + doc: Document, + sectionHref: string | undefined, + rows: ReviewRow[], + selectedRowId: string, +) => { + clearReviewMarks(doc); + const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref)); + if (!sectionRows.length) return; + + ensureReviewStyle(doc); + const paragraphs = Array.from(doc.querySelectorAll('p')); + for (const row of sectionRows) { + const active = row.id === selectedRowId; + markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active); + markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active); + } +}; + +export const __testing = { + applyReviewMarks, + applyActiveReviewMark, + clearReviewMarks, + sectionHrefAt, +}; + +const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ + bookKey, + bookDoc, +}) => { + const view = useReaderStore((state) => state.viewStates[bookKey]?.view); + const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled); + const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows); + const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || ''); + const selectRow = useReviewModeStore((state) => state.selectRow); + const selectedRowIdRef = useRef(selectedRowId); + + useEffect(() => { + selectedRowIdRef.current = selectedRowId; + }, [selectedRowId]); + + useEffect(() => { + if (!view) return; + + const cleanupLoadedDocs = () => { + for (const { doc } of view.renderer.getContents()) { + clearReviewMarks(doc); + } + }; + + if (!enabled || !rows.length) { + cleanupLoadedDocs(); + return; + } + + const applyToLoadedDocs = () => { + for (const { doc, index } of view.renderer.getContents()) { + applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current); + } + }; + + const handleClick = (event: Event) => { + const target = closestReviewTarget(event.target); + if (!target) return; + const rowId = target.getAttribute(ROW_ATTR); + if (!rowId) return; + event.preventDefault(); + event.stopPropagation(); + if ('stopImmediatePropagation' in event) { + event.stopImmediatePropagation(); + } + selectRow(bookKey, rowId); + }; + + const handleSelectionEnd = (event: Event) => { + const doc = event.currentTarget as Document | null; + if (!doc?.getSelection) return; + window.setTimeout(() => { + const target = selectedReviewTarget(doc); + const rowId = target?.getAttribute(ROW_ATTR); + if (rowId) selectRow(bookKey, rowId); + }, 0); + }; + + const listenedDocs = new Set(); + + const addDocListeners = (doc: Document) => { + if (listenedDocs.has(doc)) return; + listenedDocs.add(doc); + doc.addEventListener('click', handleClick, true); + doc.addEventListener('mouseup', handleSelectionEnd, true); + doc.addEventListener('touchend', handleSelectionEnd, true); + doc.addEventListener('keyup', handleSelectionEnd, true); + }; + + const removeDocListeners = (doc: Document) => { + listenedDocs.delete(doc); + doc.removeEventListener('click', handleClick, true); + doc.removeEventListener('mouseup', handleSelectionEnd, true); + doc.removeEventListener('touchend', handleSelectionEnd, true); + doc.removeEventListener('keyup', handleSelectionEnd, true); + }; + + const handleLoad = (event: Event) => { + const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail; + if (!detail?.doc) return; + applyReviewMarks( + detail.doc, + sectionHrefAt(bookDoc, detail.index), + rows, + selectedRowIdRef.current, + ); + addDocListeners(detail.doc); + }; + + for (const { doc } of view.renderer.getContents()) { + addDocListeners(doc); + } + applyToLoadedDocs(); + view.addEventListener('load', handleLoad); + + return () => { + view.removeEventListener('load', handleLoad); + for (const doc of listenedDocs) { + removeDocListeners(doc); + clearReviewMarks(doc); + } + }; + }, [bookDoc, bookKey, enabled, rows, selectRow, view]); + + useEffect(() => { + if (!view || !enabled || !rows.length) return; + for (const { doc } of view.renderer.getContents()) { + applyActiveReviewMark(doc, selectedRowId); + } + }, [bookKey, enabled, rows.length, selectedRowId, view]); + + return null; +}; + +export default ReviewModeController; diff --git a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx new file mode 100644 index 00000000..5dc594fc --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx @@ -0,0 +1,99 @@ +import clsx from 'clsx'; +import React from 'react'; + +import Button from '@/components/Button'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; +import { useReviewModeStore } from '@/store/reviewModeStore'; +import { eventDispatcher } from '@/utils/event'; +import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService'; + +interface ReviewModeTogglerProps { + bookKey: string; +} + +const ReviewModeToggler: React.FC = ({ bookKey }) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { setHoveredBookKey } = useReaderStore(); + const bookData = useBookDataStore((state) => state.getBookData(bookKey)); + const reviewState = useReviewModeStore((state) => state.books[bookKey]); + const { + setActiveBookKey, + setPanelVisible, + setBookLoading, + setBookError, + setBookEnabled, + setBookData, + } = useReviewModeStore(); + + const enabled = !!reviewState?.enabled; + const loading = !!reviewState?.loading; + + if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null; + + const handleToggleReviewMode = async () => { + if (appService?.isMobile) { + setHoveredBookKey(''); + } + + if (enabled) { + setBookEnabled(bookKey, false); + return; + } + + if (!bookData?.book) { + setBookError(bookKey, _('Unable to open book')); + return; + } + + setActiveBookKey(bookKey); + setPanelVisible(true); + setBookLoading(bookKey, true); + + try { + const launch = await launchInlineReviewEditor(appService, bookData.book); + const data = await loadInlineReviewData(launch.url, launch.sessionId); + setBookData(bookKey, { + baseUrl: launch.url, + sessionId: launch.sessionId, + reviewRoot: launch.reviewRoot, + version: launch.version, + session: data.session, + rows: data.rows, + gptConfig: data.gptConfig, + selectedRowId: data.rows[0]?.id || '', + }); + setBookEnabled(bookKey, true); + eventDispatcher.dispatch('toast', { + type: 'info', + message: data.rows.length + ? `审校模式已开启,共 ${data.rows.length} 段` + : '审校模式已开启,但当前 EPUB 没有识别到双语段落', + timeout: 2500, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setBookError(bookKey, message); + eventDispatcher.dispatch('toast', { + type: 'error', + message, + timeout: 3500, + }); + } + }; + + return ( + +); + +function GptConfigPanel({ + baseUrl, + sessionId, + form, + setForm, + onSaved, +}: { + baseUrl: string; + sessionId: string | null | undefined; + form: GptForm; + setForm: React.Dispatch>; + onSaved: (config: ReviewGptConfig) => void; +}) { + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + + const save = async () => { + setSaving(true); + setMessage(''); + try { + const config = await saveReviewGptConfig(baseUrl, sessionId, { + base_url: form.base_url || undefined, + model: form.model || undefined, + api_key: form.api_key, + glossary_path: form.glossary_path, + translation_prompt: form.translation_prompt, + format_prompt: form.format_prompt, + character_prompt: form.character_prompt, + }); + setForm((current) => ({ ...current, api_key: '' })); + onSaved(config); + setMessage('已保存 API、提示词与术语表路径。'); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }; + + return ( +
+ + {open ? ( +
+
+ + +
+ + + + + +
+ +
+ + +
+
+
+
+

AI 翻译 EPUB

+

选择书架中的 EPUB 后设置范围、输出格式和必要提示词。

+
+
+ + +
+
+ +
+
+
+
+

输出与范围

+ 未读取源书信息 +
+
+ + + + +
+
+ +
+
+

API 与术语表

+ 未读取配置 +
+
+ + + + +
+
+ +
+
+ +
+
+

提示词

+ +
+ + + +
+ +
+
+
+ + +
+
+
+ +
+
+
+
+

打开中日双语 EPUB

+

上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。

+
+ +
+ +
+ + +
+ +
+
+

已有审校会话

+ +
+
+
+
+
+ +
+ + +
+
+
+
目录
+

选择章节开始阅读

+
+
+
+
+ + + +
+ + +
+ + +
+
+
+
+ + +
+ + + + + + + diff --git a/apps/readest-app/tools/epub-review-editor/static/style.css b/apps/readest-app/tools/epub-review-editor/static/style.css new file mode 100644 index 00000000..35717637 --- /dev/null +++ b/apps/readest-app/tools/epub-review-editor/static/style.css @@ -0,0 +1,2023 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --panel-soft: #f8fafc; + --ink: #222426; + --muted: #68707a; + --line: #d8dee6; + --line-soft: #e7edf3; + --accent: #1d6f5f; + --accent-strong: #164d43; + --accent-soft: #e3f0ec; + --warn: #a85f00; + --bad: #a43b31; + --source: #73777f; + --mark: #fff0a6; + --shadow: 0 18px 42px rgba(25, 34, 45, 0.18); + --hover-bg: #eef3f6; + --badge-bg: #e8edf2; + --success-soft: #e9f5ee; + --marked-soft: #fff0d5; + --toast-bg: #1f2933; + --topbar-offset: 78px; + --topbar-live-height: 78px; + --reader-bg: #f4f6f8; + --reader-paper: #ffffff; + --reader-ink: #222426; + --reader-source: #73777f; + --reader-border: #e7edf3; + --reader-font-size: 17px; + --reader-line-height: 1.95; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif; + font-size: 15px; + line-height: 1.65; +} + +body[data-reader-theme='eye'] { + --reader-bg: #edf4ed; + --reader-paper: #fbfff8; + --reader-ink: #243127; + --reader-source: #6a7468; + --reader-border: #d7e4d4; + --bg: #edf4ed; +} + +body[data-reader-theme='night'] { + color-scheme: dark; + --reader-bg: #171614; + --reader-paper: #211f1b; + --reader-ink: #e8e0d2; + --reader-source: #aaa192; + --reader-border: #38342d; + --bg: #171614; + --panel: #24221e; + --panel-soft: #2c2924; + --ink: #ece5d8; + --muted: #aaa192; + --line: #3c382f; + --line-soft: #332f28; + --accent: #80bfa8; + --accent-strong: #b2dbc8; + --accent-soft: #243d34; + --source: #aaa192; + --mark: #6b5b1a; + --hover-bg: #302d27; + --badge-bg: #353128; + --success-soft: #243d34; + --marked-soft: #4a381e; + --toast-bg: #11100e; + --shadow: 0 18px 42px rgba(0, 0, 0, 0.42); +} + +button, +input, +select, +textarea { + font: inherit; +} + +.topbar { + height: var(--topbar-offset); + padding: 10px 18px; + display: grid; + grid-template-columns: auto minmax(240px, 1fr) auto auto; + align-items: center; + gap: 14px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} + +body:not(.startOpen) .topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 14; + transform: translateY(calc(8px - var(--topbar-offset))); + transition: + transform 160ms ease, + box-shadow 160ms ease; + box-shadow: 0 8px 24px rgba(25, 34, 45, 0); +} + +body:not(.startOpen).chromeVisible .topbar, +body:not(.startOpen) .topbar:hover, +body:not(.startOpen) .topbar:focus-within { + transform: translateY(0); + box-shadow: var(--shadow); +} + +.brandBlock { + min-width: 0; +} + +.bookshelfNavButton { + font-weight: 700; + border-color: var(--accent); + color: var(--accent-strong); + background: var(--accent-soft); +} + +.bookshelfNavButton.active { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +h1 { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +.versionBadge { + display: inline-flex; + align-items: center; + min-height: 20px; + margin-left: 6px; + padding: 0 6px; + border-radius: 6px; + background: var(--badge-bg); + color: var(--muted); + font-size: 12px; + font-weight: 600; + vertical-align: middle; +} + +#sessionMeta { + margin: 2px 0 0; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modeTabs { + display: inline-grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: var(--panel-soft); +} + +.modeButton { + border: 0; + border-radius: 0; + background: transparent; + min-width: 86px; +} + +.modeButton.active { + background: var(--accent); + color: #ffffff; +} + +.toolbar button.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +.toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +button { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + min-height: 34px; + padding: 5px 11px; + border-radius: 6px; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.primary, +#saveBtn, +#quickSaveBtn, +#exportBtn { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +button.primary:hover, +#saveBtn:hover, +#quickSaveBtn:hover, +#exportBtn:hover { + background: var(--accent-strong); +} + +.startScreen, +.bookshelfScreen, +.translationScreen { + min-height: calc(100vh - var(--topbar-offset)); + padding: 28px; + background: var(--bg); +} + +.startPanel, +.translationPanel { + max-width: 980px; + margin: 0 auto; + display: grid; + gap: 16px; +} + +.libraryShell { + width: min(1480px, 100%); + min-height: calc(100vh - var(--topbar-offset) - 56px); + margin: 0 auto; + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + gap: 28px; +} + +.librarySidebar { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 14px; + min-height: 0; + padding: 8px 0; +} + +.libraryBrandRow { + display: flex; + align-items: center; + gap: 10px; + height: 36px; + font-weight: 800; +} + +.iconTextButton { + width: 32px; + height: 32px; + padding: 0; + border: 0; + background: transparent; + font-size: 18px; +} + +.librarySearchLabel { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.librarySearchLabel input { + width: 100%; + border: 1px solid var(--line); + border-radius: 5px; + padding: 8px 10px; + background: var(--panel); + color: var(--ink); +} + +.libraryFacetList { + display: grid; + gap: 10px; + overflow: auto; + padding-right: 4px; +} + +.facetGroup { + display: grid; + gap: 4px; +} + +.facetGroupTitle { + padding: 10px 8px 2px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.facetButton { + width: 100%; + min-height: 36px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ink); + text-align: left; +} + +.facetButton span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.facetButton b { + min-width: 24px; + border-radius: 999px; + padding: 2px 7px; + background: var(--line-soft); + color: var(--muted); + text-align: center; + font-size: 11px; +} + +.facetButton:hover, +.facetButton.active { + background: #e8edf2; +} + +.facetButton.active { + color: var(--accent-strong); + font-weight: 800; +} + +.facetButton.active b { + background: var(--accent); + color: #ffffff; +} + +.facetEmpty { + padding: 0 8px 6px; + color: var(--muted); + font-size: 12px; +} + +.librarySidebarFooter { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.bookshelfPanel { + min-width: 0; + display: grid; + gap: 18px; + align-content: start; +} + +.startHeader, +.uploadBox, +.sessionBrowser, +.translationHeader, +.emptyBookshelf { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 18px; +} + +.startHeader, +.translationHeader { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.bookshelfHeader { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-end; + padding: 4px 0 2px; +} + +.bookshelfMeta { + color: var(--muted); + font-size: 13px; +} + +.startHeader h2, +.bookshelfHeader h2, +.translationHeader h2 { + margin: 0 0 4px; + font-size: 21px; +} + +.startHeader p, +.bookshelfHeader p, +.translationHeader p { + margin: 0; + color: var(--muted); + max-width: 680px; +} + +.bookshelfActions, +.translationHeaderActions, +.translationActions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.bookshelfGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 30px 32px; + align-items: start; +} + +.bookshelfItem { + display: grid; + gap: 10px; + border: 0; + padding: 0; + background: transparent; + cursor: pointer; + min-width: 0; +} + +.bookshelfItem:hover, +.bookshelfItem:focus-visible { + outline: 0; +} + +.bookshelfItem.active { + color: var(--accent-strong); +} + +.bookCover { + position: relative; + aspect-ratio: 2 / 3; + overflow: hidden; + border-radius: 6px; + background: #dfe6ee; + box-shadow: 0 10px 22px rgba(18, 28, 38, 0.18); +} + +.bookCover img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.bookSpine { + width: 100%; + height: 100%; + display: grid; + place-items: center; + background: linear-gradient(180deg, #45615b, #213d38); + color: #ffffff; + font-size: 34px; + font-weight: 700; +} + +.bookOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 8px; + padding: 12px; + background: rgba(18, 24, 31, 0.74); + color: #ffffff; + opacity: 0; + transition: opacity 140ms ease; +} + +.bookCover:hover .bookOverlay, +.bookshelfItem:focus-within .bookOverlay, +.bookshelfItem:focus-visible .bookOverlay { + opacity: 1; +} + +.bookOverlay h3, +.bookCaption h3 { + margin: 0; + font-size: 14px; + line-height: 1.45; +} + +.bookOverlay h3 { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.bookOverlay > p { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.bookOverlay p, +.bookCaption p { + margin: 0; + font-size: 13px; +} + +.bookFacts { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; +} + +.overlayFacts { + color: rgba(255, 255, 255, 0.82); +} + +.bookActions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.bookActions button { + min-width: 0; + border-color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + padding: 7px 8px; +} + +.bookActions [data-open-translation] { + border-color: #ffffff; + background: #ffffff; + color: #18202a; +} + +.bookActions .dangerAction { + grid-column: 1 / -1; + border-color: rgba(255, 151, 151, 0.85); + background: rgba(168, 44, 44, 0.78); + color: #ffffff; +} + +.bookCaption { + display: grid; + gap: 2px; + text-align: left; +} + +.bookCaption h3 { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.bookCaption p { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.translationPanel { + max-width: 1180px; +} + +.translationGrid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 16px; + align-items: start; +} + +.translationSettings, +.translationCard, +.translationProgressCard { + display: grid; + gap: 12px; +} + +.translationCard, +.translationProgressCard { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; +} + +.translationOptions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.translationOptions.apiOptions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.translationCard label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.translationCard input, +.translationCard select, +.translationCard textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + padding: 7px 9px; + background: var(--panel); + color: var(--ink); + font-weight: 400; +} + +.translationCard textarea { + resize: vertical; + font-size: 13px; + line-height: 1.55; +} + +.translationOutput { + border: 1px solid var(--line-soft); + border-radius: 6px; + padding: 10px; + background: var(--panel-soft); +} + +.translationOutput p { + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.translationProgressCard { + position: sticky; + top: 18px; +} + +.translationProgress { + height: 12px; + overflow: hidden; + border-radius: 999px; + background: var(--line-soft); +} + +.translationProgressBar { + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 180ms ease; +} + +.translationProgressText { + white-space: pre-wrap; + color: var(--muted); + font-size: 13px; +} + +.translationLogs { + display: grid; + gap: 8px; + max-height: 420px; + overflow: auto; +} + +.translationLog { + border-left: 3px solid var(--line); + padding: 6px 8px; + background: var(--panel-soft); +} + +.translationLog.warning { + border-left-color: var(--warn); +} + +.translationLog.error { + border-left-color: var(--bad); +} + +.translationLog span { + display: block; + color: var(--muted); + font-size: 11px; +} + +.translationLog p { + margin: 2px 0 0; + overflow-wrap: anywhere; +} + +.seriesConfigDrawer { + position: fixed; + z-index: 60; + top: 88px; + right: 18px; + bottom: 18px; + width: min(520px, calc(100vw - 36px)); + display: grid; + grid-template-rows: auto auto auto auto auto auto; + gap: 12px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: auto; +} + +.seriesConfigDrawer[hidden] { + display: none; +} + +.seriesConfigHeader { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.seriesConfigHeader h2 { + margin: 0 0 4px; + font-size: 18px; +} + +.seriesConfigHeader p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.seriesConfigDrawer label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.seriesConfigDrawer input, +.seriesConfigDrawer textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + background: var(--panel); + color: var(--ink); + font-weight: 400; +} + +.seriesConfigDrawer textarea { + resize: vertical; + line-height: 1.55; +} + +.seriesConfigActions { + display: flex; + justify-content: flex-end; +} + +.emptyBookshelf { + grid-column: 1 / -1; + display: grid; + gap: 10px; + justify-items: start; +} + +.emptyBookshelf h3 { + margin: 0; + font-size: 17px; +} + +.emptyBookshelf p { + margin: 0; + color: var(--muted); +} + +.uploadBox { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: stretch; +} + +.fileDrop { + min-width: 0; + display: grid; + gap: 4px; + border: 1px dashed #aeb8c4; + border-radius: 8px; + padding: 14px; + background: var(--panel-soft); + cursor: pointer; +} + +.fileDrop:hover { + border-color: var(--accent); +} + +.fileDrop input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.fileDrop:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12); +} + +.fileDropTitle { + font-weight: 700; +} + +.fileDropName { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compactTitle { + margin-bottom: 12px; +} + +.compactTitle span { + color: var(--muted); + font-size: 12px; +} + +.sessionList { + display: grid; + gap: 10px; +} + +.sessionItem { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + border: 1px solid var(--line-soft); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.sessionItem.active { + border-color: var(--accent); + background: var(--accent-soft); +} + +.sessionInfo { + min-width: 0; +} + +.sessionInfo h3 { + margin: 0 0 2px; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessionInfo p { + margin: 0 0 6px; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessionFacts { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.emptySession { + color: var(--muted); + border: 1px dashed var(--line); + border-radius: 8px; + padding: 18px; + text-align: center; + background: var(--panel-soft); +} + +.layout { + height: 100vh; + display: block; + min-width: 0; + position: relative; +} + +.sidebar { + position: fixed; + top: 8px; + left: 0; + bottom: 0; + z-index: 24; + width: min(380px, calc(100vw - 24px)); + border-right: 1px solid var(--line); + background: var(--panel-soft); + min-height: 0; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); +} + +.sideHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.sideHeader button { + min-height: 28px; + padding: 2px 8px; + color: var(--muted); +} + +.tocPanel, +.searchPanel { + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; +} + +.searchbox { + padding: 12px; + border-bottom: 1px solid var(--line); + display: grid; + gap: 8px; +} + +.searchbox input { + width: 100%; + min-height: 36px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 10px; + background: var(--panel); +} + +.searchScope { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: var(--panel); +} + +.searchScope button { + min-height: 30px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + padding: 3px 8px; + font-size: 13px; +} + +.searchScope button.active { + background: var(--accent); + color: #ffffff; +} + +.stats { + padding: 8px 12px; + color: var(--muted); + font-size: 12px; + border-bottom: 1px solid var(--line); +} + +.tocToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px 4px; + color: var(--ink); + font-size: 13px; +} + +.tocToolbar div { + display: flex; + gap: 4px; +} + +.tocToolbar button { + min-height: 26px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--muted); + padding: 2px 8px; + font-size: 12px; +} + +.toc { + min-height: 0; + flex: 1; + overflow: auto; + padding: 4px 0 8px; +} + +.tocChapter { + margin: 0 8px 6px; +} + +.tocChapterRow { + width: 100%; + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + border-radius: 6px; +} + +.tocToggle { + width: 24px; + height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + padding: 0; + line-height: 1; +} + +.tocToggle:disabled { + opacity: 1; + color: var(--accent); + font-size: 12px; + font-weight: 700; +} + +.tocChapterButton, +.tocPartButton, +.tocImageButton { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + text-align: left; + border: 0; + border-radius: 6px; + background: transparent; + padding: 7px 8px; +} + +.tocChapterButton { + font-weight: 700; +} + +.tocChapter.imageChapter .tocChapterButton { + font-weight: 600; +} + +.tocPartButton, +.tocImageButton { + margin-left: 24px; + width: calc(100% - 24px); + color: var(--muted); +} + +.tocImageButton { + padding-left: 14px; + font-size: 13px; +} + +.tocImageButton .tocTitle::before { + content: '插图 · '; + color: var(--accent); + font-weight: 600; +} + +.tocChapterRow:hover, +.tocChapterRow.active, +.tocChapterButton:hover, +.tocToggle:hover, +.tocPartButton:hover, +.tocImageButton:hover { + background: var(--hover-bg); +} + +.tocChapterRow.active, +.tocChapterButton.active, +.tocPartButton.active, +.tocImageButton.active { + background: var(--accent-soft); + color: var(--accent-strong); +} + +.tocTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tocCount { + color: var(--muted); + font-weight: 400; + font-size: 12px; + white-space: nowrap; +} + +.rowList { + overflow: auto; + min-height: 0; + flex: 1; +} + +.rowItem { + width: 100%; + display: block; + text-align: left; + border: 0; + border-bottom: 1px solid var(--line-soft); + border-radius: 0; + background: transparent; + padding: 10px 12px; +} + +.rowItem:hover { + background: var(--hover-bg); +} + +.rowItem.active { + background: var(--accent-soft); + border-left: 4px solid var(--accent); + padding-left: 8px; +} + +.rowItem.touched .rowBadge { + background: var(--success-soft); + color: var(--accent-strong); +} + +.rowItem.marked .rowBadge { + background: var(--marked-soft); + color: var(--warn); +} + +.emptyRowItem { + color: var(--muted); + cursor: default; +} + +.emptyRowItem:hover { + background: transparent; +} + +.imageRowItem { + cursor: default; +} + +.imageRowItem:hover { + background: var(--accent-soft); +} + +.rowMeta { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + margin-bottom: 4px; +} + +.rowBadge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 6px; + border-radius: 999px; + background: var(--badge-bg); +} + +.rowPreview { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.readerPane, +.editorPane { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.readerPane { + height: 100vh; + padding: 0; + background: var(--reader-bg); + color: var(--reader-ink); +} + +.readerPane, +.editorPane { + transition: margin-left 160ms ease; +} + +.readerHeader { + position: fixed; + top: var(--topbar-live-height); + left: 0; + right: 0; + z-index: 22; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 16px 28px; + background: var(--reader-paper); + background: color-mix(in srgb, var(--reader-paper) 94%, transparent); + border-bottom: 1px solid var(--line); + transform: translateY(calc(-100% - var(--topbar-live-height) + 8px)); + opacity: 0; + pointer-events: none; + transition: + transform 160ms ease, + opacity 160ms ease, + box-shadow 160ms ease; +} + +body.startOpen .readerHeader, +body.chromeVisible .readerHeader, +.readerHeader:hover, +.readerHeader:focus-within { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + box-shadow: 0 10px 26px rgba(25, 34, 45, 0.12); +} + +.chapterKicker { + color: var(--muted); + font-size: 12px; + margin-bottom: 2px; +} + +#readerTitle { + margin: 0; + font-size: 20px; + line-height: 1.35; +} + +.readerTools { + display: flex; + gap: 8px; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; +} + +.appearanceControls { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding-right: 8px; + border-right: 1px solid var(--line); +} + +.themeSwitch { + display: inline-grid; + grid-template-columns: repeat(3, minmax(42px, auto)); + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: var(--panel); +} + +.themeSwitch button { + min-height: 30px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + padding: 3px 9px; + font-size: 12px; +} + +.themeSwitch button.active { + background: var(--accent); + color: #ffffff; +} + +.readerSlider { + display: inline-grid; + grid-template-columns: auto 78px 34px; + gap: 6px; + align-items: center; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.readerSlider input { + width: 78px; + accent-color: var(--accent); +} + +.readerSlider output { + min-width: 30px; + color: var(--ink); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.readingFlow { + max-width: 900px; + margin: 0 auto; + padding: 36px 42px 100px; + background: var(--reader-paper); + min-height: 100vh; + border-left: 1px solid var(--reader-border); + border-right: 1px solid var(--reader-border); +} + +.readBlock { + position: relative; + padding: 18px 0 22px 12px; + border-left: 4px solid transparent; + cursor: pointer; +} + +.partDivider { + margin: 26px 0 14px; + padding: 12px 0 8px; + border-bottom: 2px solid var(--line); + color: var(--accent-strong); + font-size: 17px; +} + +.readBlock:hover { + background: linear-gradient(90deg, rgba(29, 111, 95, 0.055), transparent 42%); +} + +.readBlock.active { + border-left-color: var(--accent); +} + +.readBlock.marked { + border-left-color: var(--warn); +} + +.readSource { + color: var(--reader-source); + font-size: calc(var(--reader-font-size) - 4px); + line-height: calc(var(--reader-line-height) - 0.2); + margin-bottom: 8px; +} + +.readCn { + color: var(--reader-ink); + font-size: var(--reader-font-size); + line-height: var(--reader-line-height); + letter-spacing: 0; +} + +.readCn mark, +.readCn .review-mark, +.cnEditor mark, +.cnEditor .review-mark { + background: var(--mark); + color: inherit; +} + +.imageChapterFigure { + margin: 0 auto 28px; + padding: 0 0 22px; + border-bottom: 1px solid var(--line-soft); + text-align: center; +} + +.inlineImageFigure { + margin: 18px auto 30px; + padding-top: 8px; +} + +.imageChapterFigure img { + display: block; + max-width: 100%; + max-height: calc(100vh - 190px); + width: auto; + height: auto; + margin: 0 auto; + object-fit: contain; +} + +.imageChapterFigure figcaption { + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} + +.editorPane { + height: 100vh; + padding: 18px; +} + +.emptyState { + height: 100%; + min-height: 240px; + display: grid; + place-items: center; + color: var(--muted); +} + +.editorCard { + max-width: 1040px; + margin: 0 auto; +} + +.rowHeader { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 14px; +} + +.rowId { + font-weight: 700; + font-size: 18px; +} + +.rowPath { + color: var(--muted); + font-size: 12px; + word-break: break-all; +} + +.markToggle { + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + color: var(--warn); + font-weight: 600; +} + +.pair, +.reviewFields, +.notes, +.actions { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + margin-bottom: 12px; +} + +.pair h2, +.sectionTitle h2 { + margin: 0 0 8px; + font-size: 15px; +} + +.sourceText, +.quickSource { + color: var(--source); + white-space: pre-wrap; + background: var(--panel-soft); + border: 1px solid var(--line-soft); + border-radius: 6px; + padding: 12px; +} + +.sectionTitle { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.inlineTools { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.cnEditor { + min-height: 160px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px; + outline: none; + white-space: pre-wrap; +} + +.cnEditor:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12); +} + +.reviewFields { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: 12px; +} + +label { + display: flex; + flex-direction: column; + gap: 5px; + font-weight: 600; +} + +label input, +label select, +label textarea { + font-weight: 400; +} + +input, +select, +textarea { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + padding: 7px 9px; + color: var(--ink); +} + +textarea { + resize: vertical; +} + +.notes { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.actions { + display: flex; + justify-content: center; + gap: 10px; +} + +.quickEditor { + position: fixed; + top: 8px; + right: 0; + bottom: 0; + width: min(520px, calc(100vw - 36px)); + z-index: 26; + display: flex; + flex-direction: column; + gap: 10px; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-top: 0; + border-right: 0; + border-bottom: 0; + border-radius: 0; + box-shadow: var(--shadow); + padding: 14px; +} + +.quickHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line-soft); + padding-bottom: 10px; +} + +.quickSourceWrap { + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--panel-soft); +} + +.toolSection { + border: 1px solid var(--line-soft); + border-radius: 8px; + background: var(--panel); + padding: 10px; +} + +.toolSection > summary, +.quickSourceWrap summary { + cursor: pointer; + padding: 2px 2px 8px; + color: var(--source); + font-weight: 700; +} + +.toolSection[open] > summary { + border-bottom: 1px solid var(--line-soft); + margin-bottom: 10px; +} + +.quickSource { + max-height: none; + overflow: visible; + font-size: 13px; + border: 0; + border-radius: 0; + background: transparent; +} + +.quickField { + display: flex; + flex-direction: column; + gap: 5px; +} + +.quickCnEditor { + min-height: 150px; + max-height: 260px; + overflow: auto; +} + +.quickControls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + align-items: center; +} + +.quickActions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 2px; +} + +.gptBox { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.gptStatus { + color: var(--muted); + font-size: 12px; + margin-bottom: 8px; +} + +.gptConfig { + display: grid; + gap: 10px; + margin-bottom: 10px; +} + +.gptConfig[hidden] { + display: none !important; +} + +details.gptConfig { + margin-bottom: 0; +} + +.gptConfigGroup { + display: grid; + gap: 8px; + padding-top: 10px; + border-top: 1px solid var(--line-soft); +} + +.gptConfigGroup:first-child { + padding-top: 0; + border-top: 0; +} + +.gptConfigTitle { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.gptConfigActions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.gptConfig textarea { + min-height: 112px; + font-size: 13px; + line-height: 1.55; +} + +.glossaryMeta { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.glossaryTools { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 8px; + align-items: center; +} + +.glossaryList { + display: grid; + gap: 6px; + max-height: 280px; + overflow: auto; + padding-right: 2px; +} + +.glossaryRow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto; + gap: 6px; + align-items: center; +} + +.glossaryRow input { + min-width: 0; + font-size: 12px; +} + +.glossaryDelete { + padding-inline: 8px; +} + +.glossaryEmpty { + padding: 10px; + border: 1px dashed #cbd5df; + border-radius: 6px; + color: var(--muted); + font-size: 12px; + background: var(--panel); +} + +.gptActions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} + +.deliveryActions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.gptCandidate { + margin-top: 10px; + padding: 10px; + border: 1px dashed var(--accent); + border-radius: 6px; + background: var(--panel); + max-height: 180px; + overflow: auto; + white-space: pre-wrap; +} + +.gptCandidateMeta { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line-soft); + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.toast { + position: fixed; + right: 16px; + bottom: 16px; + max-width: min(560px, calc(100vw - 32px)); + padding: 12px 14px; + background: var(--toast-bg); + color: #ffffff; + border-radius: 8px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); + z-index: 30; + white-space: pre-wrap; +} + +[hidden] { + display: none !important; +} + +@media (min-width: 1041px) { + body.sidebarOpen .readerPane, + body.sidebarOpen .editorPane { + margin-left: 380px; + } + + body.sidebarOpen .sidebar { + box-shadow: none; + } + + body.sidebarOpen .readerHeader { + left: 380px; + } + + body.quickOpen .readerHeader { + right: min(520px, calc(100vw - 36px)); + } +} + +@media (min-width: 1320px) { + body.quickOpen .readingFlow { + margin-left: 56px; + margin-right: 560px; + max-width: 840px; + } +} + +@media (max-width: 1040px) { + .topbar { + height: auto; + grid-template-columns: 1fr; + align-items: stretch; + } + + body:not(.startOpen) .topbar { + transform: translateY(calc(-100% + 8px)); + } + + #sessionMeta { + white-space: normal; + } + + .toolbar { + justify-content: flex-start; + } + + .layout { + height: auto; + min-height: 100vh; + } + + .sidebar { + top: 0; + width: min(360px, calc(100vw - 18px)); + max-height: none; + border-right: 0; + } + + .readerPane, + .editorPane { + height: auto; + min-height: 70vh; + } + + .readingFlow { + border: 0; + padding: 18px 18px 80px; + } + + .readerHeader { + top: var(--topbar-live-height); + padding: 12px 16px; + } + + .appearanceControls { + width: 100%; + padding-right: 0; + padding-bottom: 8px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .reviewFields, + .quickControls { + grid-template-columns: 1fr; + } + + .quickEditor { + top: auto; + left: 10px; + right: 10px; + bottom: 10px; + width: auto; + max-height: 72vh; + border: 1px solid var(--line); + border-radius: 8px; + } + + .startScreen, + .bookshelfScreen, + .translationScreen { + padding: 14px; + } + + .startHeader, + .translationHeader, + .uploadBox, + .sessionItem, + .deliveryActions { + grid-template-columns: 1fr; + } + + .libraryShell { + grid-template-columns: 1fr; + gap: 18px; + } + + .librarySidebar { + position: static; + grid-template-rows: auto; + } + + .bookshelfHeader { + display: grid; + gap: 12px; + } + + .bookshelfActions, + .translationHeaderActions, + .bookshelfActions button, + .translationHeaderActions button { + width: 100%; + } + + .bookshelfGrid { + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 22px 18px; + } + + .bookOverlay { + position: absolute; + opacity: 1; + min-height: 0; + background: rgba(18, 24, 31, 0.68); + } + + .translationGrid { + grid-template-columns: 1fr; + } + + .translationProgressCard { + position: static; + } + + .translationOptions, + .translationOptions.apiOptions { + grid-template-columns: 1fr; + } + + .seriesConfigDrawer { + top: 12px; + right: 12px; + bottom: 12px; + width: calc(100vw - 24px); + } +} + +@media (max-width: 680px) { + .glossaryTools, + .glossaryRow { + grid-template-columns: 1fr; + } +} diff --git a/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py b/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py new file mode 100644 index 00000000..69f902e9 --- /dev/null +++ b/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py @@ -0,0 +1,184 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +SERVER_PATH = Path(__file__).with_name("server.py") +SPEC = importlib.util.spec_from_file_location("epub_review_editor_server", SERVER_PATH) +server = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(server) + + +def write_json(path: Path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +class InlineReviewSessionScopeTest(unittest.TestCase): + def test_sanitize_cn_html_keeps_reader_review_inline_markup(self): + sanitized = server.sanitize_cn_html( + '文字注音' + '(注:注释)' + '' + ) + + self.assertEqual( + sanitized, + '文字注音' + '(注:注释)', + ) + + def make_session( + self, + review_root: Path, + session_id: str, + epub_path: Path, + glossary_path: Path, + ) -> Path: + session_root = review_root / session_id + write_json( + session_root / "review_state" / "session.json", + { + "id": session_id, + "source_name": epub_path.name, + "source_epub": str(epub_path), + }, + ) + write_json( + session_root / "review_state" / "state.json", + { + "source_epub": str(epub_path), + "edits": {}, + }, + ) + write_json( + session_root / "review_state" / "gpt_config.json", + { + "base_url": "https://session.example.test/v1", + "model": "session-model", + "glossary_path": str(glossary_path), + "translation_prompt": "session translation prompt", + "format_prompt": "session format prompt", + "character_prompt": "session character prompt", + }, + ) + return session_root + + def test_inline_retranslate_uses_request_session_config_and_glossary(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + review_root = root / "review-root" + global_glossary = root / "global-glossary.json" + session_glossary = root / "session-glossary.json" + write_json(global_glossary, {"別語": "全局译名"}) + write_json(session_glossary, {"ファルシオン": "大砍刀"}) + write_json( + review_root / "gpt_config.json", + { + "api_key": "test-key", + "base_url": "https://global.example.test/v1", + "model": "global-model", + "glossary_path": str(global_glossary), + }, + ) + + epub_path = root / "book.epub" + epub_path.write_bytes(b"not a real epub") + session_root = self.make_session(review_root, "session-a", epub_path, session_glossary) + write_json( + session_root / "review_state" / "rows_meta.json", + {"parser_version": server.ROWS_PARSER_VERSION}, + ) + write_json( + session_root / "review_state" / "rows.json", + [ + { + "id": "R00001", + "file": "chapter.xhtml", + "file_label": "chapter.xhtml", + "document_title": "chapter", + "ja_p_index": 0, + "cn_p_index": 1, + "jp_html": "ファルシオンFalchion", + "jp_text": "ファルシオン", + "cn_html": "旧译文", + } + ], + ) + + captured = {} + original_call = server.call_openai_compatible_chat + + def fake_call(config, messages, temperature=0.2, timeout=90): + captured["config"] = config + captured["messages"] = messages + return "候选译文" + + server.call_openai_compatible_chat = fake_call + try: + app = server.create_app(review_root) + response = app.test_client().post( + "/api/row/R00001/retranslate?session_id=session-a", + json={"instruction": ""}, + ) + finally: + server.call_openai_compatible_chat = original_call + + self.assertEqual(response.status_code, 200, response.get_data(as_text=True)) + payload = response.get_json() + self.assertEqual(payload["model"], "session-model") + self.assertEqual(payload["glossary_path"], str(session_glossary)) + self.assertEqual(payload["glossary_matches"], ["ファルシオン => 大砍刀"]) + self.assertEqual(captured["config"]["base_url"], "https://session.example.test/v1") + self.assertEqual(captured["config"]["translation_prompt"], "session translation prompt") + prompt_text = "\n".join(message["content"] for message in captured["messages"]) + self.assertIn("ファルシオン => 大砍刀", prompt_text) + self.assertNotIn("全局译名", prompt_text) + + def test_scoped_gpt_config_saves_secret_globally_and_public_response_hides_it(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + review_root = root / "review-root" + glossary = root / "glossary.json" + write_json(glossary, {}) + write_json(review_root / "gpt_config.json", {"glossary_path": str(glossary)}) + epub_path = root / "book.epub" + epub_path.write_bytes(b"not a real epub") + self.make_session(review_root, "session-a", epub_path, glossary) + + app = server.create_app(review_root) + response = app.test_client().post( + "/api/gpt/config?session_id=session-a", + json={ + "api_key": "session-secret", + "base_url": "https://session.example.test/v1", + "model": "session-model", + "glossary_path": str(glossary), + "translation_prompt": "session translation prompt", + "format_prompt": "session format prompt", + "character_prompt": "session character prompt", + }, + ) + + self.assertEqual(response.status_code, 200, response.get_data(as_text=True)) + payload = response.get_json() + self.assertTrue(payload["configured"]) + self.assertNotIn("api_key", payload) + self.assertEqual(payload["base_url"], "https://session.example.test/v1") + self.assertEqual( + json.loads((review_root / "gpt_config.json").read_text(encoding="utf-8"))["api_key"], + "session-secret", + ) + scoped = json.loads( + (review_root / "session-a" / "review_state" / "gpt_config.json").read_text( + encoding="utf-8" + ) + ) + self.assertNotIn("api_key", scoped) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/readest-app/tools/epub-review-editor/version.py b/apps/readest-app/tools/epub-review-editor/version.py new file mode 100644 index 00000000..2f7fd6da --- /dev/null +++ b/apps/readest-app/tools/epub-review-editor/version.py @@ -0,0 +1,7 @@ +version = "0.16.5" + +VERSION_RULES = { + "PATCH": "修复 bug 或兼容性小修", + "MINOR": "新增向后兼容能力、API 字段或可选配置", + "MAJOR": "破坏兼容的 API、配置或行为变更", +} diff --git a/package.json b/package.json index a9ae377c..34bbef02 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint:lua": "pnpm --filter @readest/readest-app lint:lua", "tauri": "pnpm --filter @readest/readest-app tauri", "dev-web": "pnpm --filter @readest/readest-app dev-web", + "epub-reviewer:dev": "pnpm --filter @readest/readest-app epub-reviewer:dev", "prepare": "husky", "fmt:check": "pnpm --filter @readest/readest-app fmt:check", "clippy:check": "pnpm --filter @readest/readest-app clippy:check", diff --git a/scripts/open-readest-latest.cmd b/scripts/open-readest-latest.cmd new file mode 100644 index 00000000..de444c58 --- /dev/null +++ b/scripts/open-readest-latest.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0open-readest-latest.ps1" %* diff --git a/scripts/open-readest-latest.ps1 b/scripts/open-readest-latest.ps1 new file mode 100644 index 00000000..3ccfce58 --- /dev/null +++ b/scripts/open-readest-latest.ps1 @@ -0,0 +1,162 @@ +param( + [string]$Remote = "akai-tools", + [string]$Branch = "codex/desktop-review-editor-blocks", + [switch]$SkipPull, + [switch]$CheckOnly, + [switch]$KeepRunning +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Invoke-Checked { + param( + [string]$Command, + [string[]]$Arguments, + [string]$WorkingDirectory = $RepoRoot + ) + + Push-Location $WorkingDirectory + try { + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } +} + +function Add-PathIfExists { + param([string]$PathToAdd) + if ($PathToAdd -and (Test-Path -LiteralPath $PathToAdd)) { + $script:PathParts += $PathToAdd + } +} + +function Stop-OldReadestDev { + if ($KeepRunning) { + return + } + + Write-Step "Stopping old Readest desktop/dev processes" + Get-Process -Name "Readest" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + + $repoLower = $RepoRoot.ToLowerInvariant() + $currentPid = $PID + Get-CimInstance Win32_Process | + Where-Object { + $_.ProcessId -ne $currentPid -and + $_.CommandLine -and + $_.CommandLine.ToLowerInvariant().Contains($repoLower) -and + ($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app|epub-reviewer:dev") + } | + ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} + +function Assert-CleanWorktree { + $status = (& git -C $RepoRoot status --porcelain) + if ($status) { + Write-Host "" + Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow + & git -C $RepoRoot status --short + throw "Commit, stash, or discard local changes before launching the latest organization version." + } +} + +try { + $PathParts = @() + $runtimeRoot = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies" + Add-PathIfExists "C:\w64devkit\bin" + Add-PathIfExists (Join-Path $runtimeRoot "bin") + Add-PathIfExists (Join-Path $runtimeRoot "node\bin") + Add-PathIfExists (Join-Path $env:USERPROFILE ".cargo\bin") + $env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";") + $env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu" + + foreach ($command in @("git", "pnpm", "cargo")) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "Required command not found: $command" + } + } + + Write-Step "Readest latest launcher" + Write-Host "Repository: $RepoRoot" + Write-Host "Remote: $Remote" + Write-Host "Branch: $Branch" + + $beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim() + + if (-not $SkipPull) { + Assert-CleanWorktree + + Write-Step "Fetching latest code from organization remote" + Invoke-Checked "git" @("-C", $RepoRoot, "fetch", "--prune", $Remote) + + $currentBranch = (& git -C $RepoRoot branch --show-current).Trim() + if ($currentBranch -ne $Branch) { + Write-Step "Switching to $Branch" + & git -C $RepoRoot show-ref --verify --quiet "refs/heads/$Branch" + if ($LASTEXITCODE -eq 0) { + Invoke-Checked "git" @("-C", $RepoRoot, "switch", $Branch) + } else { + Invoke-Checked "git" @("-C", $RepoRoot, "switch", "--track", "-c", $Branch, "$Remote/$Branch") + } + } + + Write-Step "Fast-forwarding local branch" + Invoke-Checked "git" @("-C", $RepoRoot, "pull", "--ff-only", $Remote, $Branch) + } else { + Write-Step "Skipping git pull by request" + } + + $afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim() + + Write-Step "Updating submodules" + Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive") + + $changedFiles = @() + if ($beforeHead -ne $afterHead) { + $changedFiles = (& git -C $RepoRoot diff --name-only $beforeHead $afterHead) + } + + $needsInstall = + -not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules")) -or + ($changedFiles -contains "pnpm-lock.yaml") -or + ($changedFiles -contains "package.json") -or + ($changedFiles -contains "apps/readest-app/package.json") + + if ($needsInstall) { + Write-Step "Installing/updating dependencies" + Invoke-Checked "pnpm" @("install", "--frozen-lockfile") + } + + if ($CheckOnly) { + Write-Step "Launcher check complete" + exit 0 + } + + Stop-OldReadestDev + + Write-Step "Starting Readest desktop with the latest code" + Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server." + Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev") +} catch { + Write-Host "" + Write-Host "Readest launcher failed:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "" + Write-Host "Press Enter to close this window." + [void][Console]::ReadLine() + exit 1 +}