refactor: streamline RSVP reader for The Well of Ascension single-book focus
- Clean up legacy multi-format/CJK modules (orp, epub, samples, modals) - Replace with lightweight pure English tokenizer and chunking engine - Add SettingsDrawer, auto-hiding HUD, and PWA icons - Update manifest and touch icons Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
530
src/App.tsx
530
src/App.tsx
@@ -1,410 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChapterDrawer } from './components/ChapterDrawer';
|
||||
import { Controls } from './components/Controls';
|
||||
import { Settings as SettingsIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FocusDisplay } from './components/FocusDisplay';
|
||||
import { Header } from './components/Header';
|
||||
import { LibraryModal } from './components/LibraryModal';
|
||||
import { PasteModal } from './components/PasteModal';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { ShortcutsModal } from './components/ShortcutsModal';
|
||||
import {
|
||||
deleteBook,
|
||||
getActiveBookId,
|
||||
getAllBooksMeta,
|
||||
getBook,
|
||||
loadSettings,
|
||||
saveBook,
|
||||
saveSettings,
|
||||
setActiveBookId,
|
||||
} from './db';
|
||||
import { getBuiltinSampleBooks } from './db/samples';
|
||||
import { SettingsDrawer } from './components/SettingsDrawer';
|
||||
import { calculateWpm } from './core/timing';
|
||||
import { tokenizeEnglishText } from './core/tokenizer';
|
||||
import { loadSavedState, saveState } from './db';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { useReaderEngine } from './hooks/useReaderEngine';
|
||||
import type { Book, BookMeta, Chapter, ReaderSettings, ThemeId } from './types';
|
||||
|
||||
const THEME_ORDER: ThemeId[] = ['oled', 'dark', 'slate', 'sepia', 'nord', 'paper'];
|
||||
import type { FontSize, FocusState } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [settings, setSettings] = useState<ReaderSettings>(() => loadSettings());
|
||||
const [booksMeta, setBooksMeta] = useState<BookMeta[]>([]);
|
||||
const [activeBook, setActiveBook] = useState<Book | null>(null);
|
||||
const [saved] = useState<FocusState>(() => loadSavedState());
|
||||
const [flashesPerMinute, setFlashesPerMinute] = useState<number>(
|
||||
saved.flashesPerMinute
|
||||
);
|
||||
const [wordsPerChunk, setWordsPerChunk] = useState<number>(saved.wordsPerChunk);
|
||||
const [fontSize, setFontSize] = useState<FontSize>(saved.fontSize);
|
||||
const [words, setWords] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Modals
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState<boolean>(false);
|
||||
const [isPasteOpen, setIsPasteOpen] = useState<boolean>(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
|
||||
const [isShortcutsOpen, setIsShortcutsOpen] = useState<boolean>(false);
|
||||
const [isChaptersOpen, setIsChaptersOpen] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
|
||||
// HUD inactivity fading
|
||||
const [isUserActive, setIsUserActive] = useState<boolean>(true);
|
||||
const inactivityTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Reader engine
|
||||
const {
|
||||
currentIndex,
|
||||
currentToken,
|
||||
previousToken,
|
||||
nextToken,
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
jumpToChapter,
|
||||
restart,
|
||||
totalTokens,
|
||||
} = useReaderEngine({
|
||||
book: activeBook,
|
||||
settings,
|
||||
onBookProgressUpdate: (bookId, progress) => {
|
||||
setBooksMeta((prev) =>
|
||||
prev.map((b) => (b.id === bookId ? { ...b, currentProgress: progress } : b))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Track fullscreen changes
|
||||
// Load built-in book text
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
console.warn('Fullscreen request failed:', err);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().catch((err) => {
|
||||
console.warn('Exit fullscreen failed:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load of books and defaults
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
async function loadBook() {
|
||||
try {
|
||||
let metaList = await getAllBooksMeta();
|
||||
|
||||
if (metaList.length === 0) {
|
||||
// First visit: inject curated built-in samples
|
||||
const samples = getBuiltinSampleBooks();
|
||||
for (const s of samples) {
|
||||
await saveBook(s);
|
||||
}
|
||||
metaList = await getAllBooksMeta();
|
||||
}
|
||||
|
||||
setBooksMeta(metaList);
|
||||
|
||||
// Load active book
|
||||
const savedActiveId = getActiveBookId();
|
||||
const targetId = savedActiveId || metaList[0]?.id;
|
||||
|
||||
if (targetId) {
|
||||
const loadedBook = await getBook(targetId);
|
||||
if (loadedBook) {
|
||||
setActiveBook(loadedBook);
|
||||
setActiveBookId(loadedBook.id);
|
||||
}
|
||||
const res = await fetch(
|
||||
encodeURI('/books/The Well of Ascension.txt')
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load book: ${res.statusText}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
const parsedWords = tokenizeEnglishText(text);
|
||||
setWords(parsedWords);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize reader database:', err);
|
||||
console.error('Failed to load book:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
loadBook();
|
||||
}, []);
|
||||
|
||||
// Update settings handler
|
||||
const handleUpdateSettings = (newSettings: Partial<ReaderSettings>) => {
|
||||
setSettings((prev) => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
});
|
||||
// Sync state changes to localStorage
|
||||
const handleFpmChange = (newFpm: number) => {
|
||||
const clamped = Math.min(600, Math.max(60, newFpm));
|
||||
setFlashesPerMinute(clamped);
|
||||
saveState({ flashesPerMinute: clamped });
|
||||
};
|
||||
|
||||
// Theme cycle
|
||||
const handleCycleTheme = () => {
|
||||
const currentIdx = THEME_ORDER.indexOf(settings.theme);
|
||||
const nextIdx = (currentIdx + 1) % THEME_ORDER.length;
|
||||
handleUpdateSettings({ theme: THEME_ORDER[nextIdx] });
|
||||
const handleWordsPerChunkChange = (newChunk: number) => {
|
||||
const clamped = Math.min(4, Math.max(1, newChunk));
|
||||
setWordsPerChunk(clamped);
|
||||
saveState({ wordsPerChunk: clamped });
|
||||
};
|
||||
|
||||
// Book selection
|
||||
const handleSelectBook = async (bookId: string) => {
|
||||
const book = await getBook(bookId);
|
||||
if (book) {
|
||||
setActiveBook(book);
|
||||
setActiveBookId(book.id);
|
||||
const handleFontSizeChange = (newSize: FontSize) => {
|
||||
setFontSize(newSize);
|
||||
saveState({ fontSize: newSize });
|
||||
};
|
||||
|
||||
// Throttled persistence of wordIndex to localStorage
|
||||
const lastSavedIndexRef = useRef<number>(saved.wordIndex);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const handleWordIndexUpdate = useCallback((index: number) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
// Save new book
|
||||
const handleSaveNewBook = async (newBook: Book) => {
|
||||
await saveBook(newBook);
|
||||
const updatedMeta = await getAllBooksMeta();
|
||||
setBooksMeta(updatedMeta);
|
||||
};
|
||||
|
||||
// Delete book
|
||||
const handleDeleteBook = async (bookId: string) => {
|
||||
await deleteBook(bookId);
|
||||
const updatedMeta = await getAllBooksMeta();
|
||||
setBooksMeta(updatedMeta);
|
||||
|
||||
if (activeBook?.id === bookId) {
|
||||
if (updatedMeta.length > 0) {
|
||||
handleSelectBook(updatedMeta[0].id);
|
||||
} else {
|
||||
setActiveBook(null);
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
if (index !== lastSavedIndexRef.current) {
|
||||
lastSavedIndexRef.current = index;
|
||||
saveState({ wordIndex: index });
|
||||
}
|
||||
}
|
||||
};
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// Calculate current chapter based on currentIndex
|
||||
const currentChapter = useMemo(() => {
|
||||
if (!activeBook || !activeBook.chapters || activeBook.chapters.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const chapters = activeBook.chapters;
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
if (currentIndex >= chapters[i].startIndex) {
|
||||
return chapters[i];
|
||||
}
|
||||
}
|
||||
return chapters[0];
|
||||
}, [activeBook, currentIndex]);
|
||||
// Save immediately on unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
saveState({
|
||||
wordIndex: lastSavedIndexRef.current,
|
||||
flashesPerMinute,
|
||||
wordsPerChunk,
|
||||
fontSize,
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [flashesPerMinute, wordsPerChunk, fontSize]);
|
||||
|
||||
// Jump to next/prev chapter
|
||||
const handlePrevChapter = () => {
|
||||
if (!activeBook || !activeBook.chapters) return;
|
||||
const chapters = activeBook.chapters;
|
||||
const currentIdx = currentChapter
|
||||
? chapters.findIndex((c) => c.id === currentChapter.id)
|
||||
: 0;
|
||||
if (currentIdx > 0) {
|
||||
jumpToChapter(chapters[currentIdx - 1]);
|
||||
} else {
|
||||
jumpTo(0);
|
||||
}
|
||||
};
|
||||
// Core reading engine
|
||||
const {
|
||||
currentIndex,
|
||||
currentChunkText,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
pause,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
restart,
|
||||
totalWords,
|
||||
} = useReaderEngine({
|
||||
words,
|
||||
flashesPerMinute,
|
||||
wordsPerChunk,
|
||||
initialWordIndex: saved.wordIndex,
|
||||
onWordIndexChange: (idx) => {
|
||||
lastSavedIndexRef.current = idx;
|
||||
handleWordIndexUpdate(idx);
|
||||
},
|
||||
});
|
||||
|
||||
const handleNextChapter = () => {
|
||||
if (!activeBook || !activeBook.chapters) return;
|
||||
const chapters = activeBook.chapters;
|
||||
const currentIdx = currentChapter
|
||||
? chapters.findIndex((c) => c.id === currentChapter.id)
|
||||
: 0;
|
||||
if (currentIdx < chapters.length - 1) {
|
||||
jumpToChapter(chapters[currentIdx + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse/touch/key activity detection for HUD fading
|
||||
// Reset inactivity timer
|
||||
const resetInactivity = useCallback(() => {
|
||||
setIsUserActive(true);
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
if (isPlaying && settings.autoHideControls) {
|
||||
if (isPlaying) {
|
||||
inactivityTimerRef.current = window.setTimeout(() => {
|
||||
setIsUserActive(false);
|
||||
}, 3000);
|
||||
}, 2500);
|
||||
}
|
||||
}, [isPlaying, settings.autoHideControls]);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !settings.autoHideControls) {
|
||||
if (!isPlaying) {
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
inactivityTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handleInteraction = () => {
|
||||
resetInactivity();
|
||||
};
|
||||
const onActivity = () => resetInactivity();
|
||||
window.addEventListener('mousemove', onActivity);
|
||||
window.addEventListener('keydown', onActivity);
|
||||
window.addEventListener('touchstart', onActivity);
|
||||
|
||||
window.addEventListener('mousemove', handleInteraction);
|
||||
window.addEventListener('keydown', handleInteraction);
|
||||
window.addEventListener('touchstart', handleInteraction);
|
||||
window.addEventListener('pointerdown', handleInteraction);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
inactivityTimerRef.current = window.setTimeout(() => {
|
||||
setIsUserActive(false);
|
||||
}, 3000);
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('mousemove', handleInteraction);
|
||||
window.removeEventListener('keydown', handleInteraction);
|
||||
window.removeEventListener('touchstart', handleInteraction);
|
||||
window.removeEventListener('pointerdown', handleInteraction);
|
||||
window.removeEventListener('mousemove', onActivity);
|
||||
window.removeEventListener('keydown', onActivity);
|
||||
window.removeEventListener('touchstart', onActivity);
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, settings.autoHideControls, resetInactivity]);
|
||||
}, [isPlaying, resetInactivity]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const anyModalOpen =
|
||||
isLibraryOpen ||
|
||||
isPasteOpen ||
|
||||
isSettingsOpen ||
|
||||
isShortcutsOpen ||
|
||||
isChaptersOpen;
|
||||
// Fullscreen toggle
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardShortcuts({
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm: (delta) =>
|
||||
handleUpdateSettings({
|
||||
wpm: Math.max(100, Math.min(1200, settings.wpm + delta)),
|
||||
}),
|
||||
changeFpm: (delta) => handleFpmChange(flashesPerMinute + delta),
|
||||
toggleFullscreen,
|
||||
restart,
|
||||
prevChapter: handlePrevChapter,
|
||||
nextChapter: handleNextChapter,
|
||||
onEscape: () => {
|
||||
if (anyModalOpen) {
|
||||
setIsLibraryOpen(false);
|
||||
setIsPasteOpen(false);
|
||||
if (isSettingsOpen) {
|
||||
setIsSettingsOpen(false);
|
||||
setIsShortcutsOpen(false);
|
||||
setIsChaptersOpen(false);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
},
|
||||
isModalOpen: anyModalOpen,
|
||||
isDrawerOpen: isSettingsOpen,
|
||||
});
|
||||
|
||||
const themeClass = `theme-${settings.theme}`;
|
||||
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
|
||||
const hudClasses = isHudDimmed
|
||||
? 'opacity-0 pointer-events-none transition-opacity duration-500'
|
||||
: 'opacity-100 pointer-events-auto transition-opacity duration-300';
|
||||
const progressPercent =
|
||||
totalWords > 0
|
||||
? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100))
|
||||
: 0;
|
||||
|
||||
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
|
||||
const hudOpacity =
|
||||
isPlaying && !isUserActive
|
||||
? 'opacity-0 pointer-events-none'
|
||||
: 'opacity-100 pointer-events-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseMove={resetInactivity}
|
||||
onTouchStart={resetInactivity}
|
||||
className={`relative w-full h-full flex flex-col justify-between overflow-hidden transition-colors duration-300 font-sans ${themeClass}`}
|
||||
className="relative w-full h-full flex flex-col justify-between bg-[#09090b] text-[#f4f4f5] font-sans overflow-hidden select-none"
|
||||
>
|
||||
{/* Top Header HUD */}
|
||||
<div className={`w-full z-20 shrink-0 ${hudClasses}`}>
|
||||
<Header
|
||||
book={activeBook}
|
||||
currentChapter={currentChapter}
|
||||
settings={settings}
|
||||
isFullscreen={isFullscreen}
|
||||
onOpenLibrary={() => setIsLibraryOpen(true)}
|
||||
onOpenPaste={() => setIsPasteOpen(true)}
|
||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||
onOpenShortcuts={() => setIsShortcutsOpen(true)}
|
||||
onOpenChapters={() => setIsChaptersOpen(true)}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCycleTheme={handleCycleTheme}
|
||||
/>
|
||||
</div>
|
||||
{/* Top Subtle Bar */}
|
||||
<header
|
||||
className={`w-full flex items-center justify-between px-6 py-4 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
|
||||
>
|
||||
<span className="text-[11px] font-mono tracking-widest text-zinc-500 uppercase">
|
||||
The Well of Ascension
|
||||
</span>
|
||||
|
||||
{/* Main RSVP Focus Arena */}
|
||||
<main className="flex-1 min-h-0 flex flex-col items-center justify-center px-2 sm:px-4 z-10 w-full overflow-hidden my-auto">
|
||||
{/* Quiet Gear Button */}
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
title="Settings [Esc]"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main RSVPs Central Stream */}
|
||||
<main className="flex-1 min-h-0 flex items-center justify-center w-full z-10 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center space-y-3 text-neutral-400">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-rose-500 border-t-transparent animate-spin" />
|
||||
<span className="text-sm font-mono tracking-wider">正在加载 Focus 阅读器...</span>
|
||||
<div className="flex flex-col items-center space-y-3 text-zinc-500">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
|
||||
<span className="text-xs font-mono tracking-wider">
|
||||
Loading Focus...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<FocusDisplay
|
||||
token={currentToken}
|
||||
previousToken={previousToken}
|
||||
nextToken={nextToken}
|
||||
fontSize={settings.fontSize}
|
||||
fontFamily={settings.fontFamily}
|
||||
orpColor={settings.orpColor}
|
||||
showGuideLines={settings.showGuideLines}
|
||||
showContextPreview={settings.showContextPreview}
|
||||
chunkText={currentChunkText}
|
||||
fontSize={fontSize}
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={togglePlay}
|
||||
onTogglePlay={() => {
|
||||
resetInactivity();
|
||||
togglePlay();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Bottom Controls HUD */}
|
||||
<div className={`w-full pb-2 sm:pb-6 short:pb-1 z-20 shrink-0 ${hudClasses}`}>
|
||||
<Controls
|
||||
currentIndex={currentIndex}
|
||||
totalTokens={totalTokens}
|
||||
tokens={activeBook?.tokens || []}
|
||||
chapters={activeBook?.chapters || []}
|
||||
isPlaying={isPlaying}
|
||||
settings={settings}
|
||||
onTogglePlay={togglePlay}
|
||||
onJumpTo={jumpTo}
|
||||
onJumpBy={jumpBy}
|
||||
onJumpToPreviousSentence={jumpToPreviousSentence}
|
||||
onJumpToNextSentence={jumpToNextSentence}
|
||||
onRestart={restart}
|
||||
onChangeWpm={(newWpm) => handleUpdateSettings({ wpm: newWpm })}
|
||||
/>
|
||||
</div>
|
||||
{/* Bottom Ultra-Thin Progress & Speed HUD */}
|
||||
<footer
|
||||
className={`w-full flex flex-col space-y-2 pb-4 px-6 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
|
||||
>
|
||||
{/* Ultra-thin progress line */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickPos = (e.clientX - rect.left) / rect.width;
|
||||
const target = Math.round(clickPos * (totalWords - 1));
|
||||
jumpTo(target);
|
||||
}}
|
||||
className="relative w-full h-1 bg-zinc-800/80 hover:h-2 rounded-full cursor-pointer transition-all group overflow-hidden"
|
||||
title="点击调整进度"
|
||||
>
|
||||
<div
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals & Drawers */}
|
||||
<LibraryModal
|
||||
isOpen={isLibraryOpen}
|
||||
books={booksMeta}
|
||||
activeBookId={activeBook?.id || null}
|
||||
onClose={() => setIsLibraryOpen(false)}
|
||||
onSelectBook={handleSelectBook}
|
||||
onSaveNewBook={handleSaveNewBook}
|
||||
onDeleteBook={handleDeleteBook}
|
||||
/>
|
||||
{/* Status text */}
|
||||
<div className="flex items-center justify-between text-[11px] font-mono text-zinc-500">
|
||||
<span>
|
||||
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '}
|
||||
<span className="opacity-70">({progressPercent.toFixed(1)}%)</span>
|
||||
</span>
|
||||
|
||||
<PasteModal
|
||||
isOpen={isPasteOpen}
|
||||
wpm={settings.wpm}
|
||||
onClose={() => setIsPasteOpen(false)}
|
||||
onSaveAndPlay={async (newBook) => {
|
||||
await handleSaveNewBook(newBook);
|
||||
setActiveBook(newBook);
|
||||
setActiveBookId(newBook.id);
|
||||
play();
|
||||
}}
|
||||
/>
|
||||
<span className="text-zinc-400 font-semibold">
|
||||
{wpm} WPM{' '}
|
||||
<span className="font-normal text-zinc-500 text-[10px]">
|
||||
({flashesPerMinute} FPM × {wordsPerChunk})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<SettingsModal
|
||||
{/* Slide-out Settings Drawer */}
|
||||
<SettingsDrawer
|
||||
isOpen={isSettingsOpen}
|
||||
settings={settings}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
/>
|
||||
|
||||
<ShortcutsModal
|
||||
isOpen={isShortcutsOpen}
|
||||
onClose={() => setIsShortcutsOpen(false)}
|
||||
/>
|
||||
|
||||
<ChapterDrawer
|
||||
isOpen={isChaptersOpen}
|
||||
chapters={activeBook?.chapters || []}
|
||||
currentChapterId={currentChapter?.id}
|
||||
onClose={() => setIsChaptersOpen(false)}
|
||||
onSelectChapter={(chapter: Chapter) => jumpToChapter(chapter)}
|
||||
flashesPerMinute={flashesPerMinute}
|
||||
onChangeFpm={handleFpmChange}
|
||||
wordsPerChunk={wordsPerChunk}
|
||||
onChangeWordsPerChunk={handleWordsPerChunkChange}
|
||||
fontSize={fontSize}
|
||||
onChangeFontSize={handleFontSizeChange}
|
||||
wordIndex={currentIndex}
|
||||
totalWords={totalWords}
|
||||
onJumpTo={jumpTo}
|
||||
onResetProgress={() => {
|
||||
restart();
|
||||
saveState({ wordIndex: 0 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user