diff --git a/client/App.tsx b/client/App.tsx deleted file mode 100644 index 251a969..0000000 --- a/client/App.tsx +++ /dev/null @@ -1,2245 +0,0 @@ -import { useState, useEffect, useCallback, useMemo, useDeferredValue, useRef } from 'react' -import { toast } from '@client/lib/toast' -import { Plus, BookOpen, X, FileDown, Pencil, Star, Tags, Library, ClipboardCheck, Eye, Image, Zap, Download, Trash2, RotateCcw, Headphones, FolderOpen } from 'lucide-react' -import { DndContext, DragOverlay, closestCenter, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core' -import { SortableContext, rectSortingStrategy, verticalListSortingStrategy } from '@dnd-kit/sortable' -import { Button } from '@client/components/ui/button' -import { Badge } from '@client/components/ui/badge' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@client/components/ui/dialog' -import { BookCard } from '@client/components/BookCard' -import { SortableBookCard } from '@client/components/SortableBookCard' -import { SortableSeriesCard } from '@client/components/SortableSeriesCard' -import { LibraryToolbar } from '@client/components/LibraryToolbar' -import { StarRating } from '@client/components/StarRating' -import { NoiseOverlay } from '@client/components/NoiseOverlay' -import { SettingsMenu } from '@client/components/SettingsMenu' -import { WizardModal } from '@client/components/WizardModal' -import { CreationView } from '@client/components/CreationView' -import { BookOverviewModal } from '@client/components/BookOverviewModal' -import { CoverGenerationModal } from '@client/components/CoverGenerationModal' -import { GenerateAllModal } from '@client/components/GenerateAllModal' -import { BackgroundTasksFooter } from '@client/components/BackgroundTasksFooter' -import { EditTagsDialog } from '@client/components/EditTagsDialog' -import { ImportPreviewDialog } from '@client/components/ImportPreviewDialog' -import { SetSeriesDialog } from '@client/components/SetSeriesDialog' -import { AudiobookDownloadModal } from '@client/components/AudiobookDownloadModal' -import { AudiobookVoiceModal } from '@client/components/AudiobookVoiceModal' -import { AudiobookRegenerateConfirmModal } from '@client/components/AudiobookRegenerateConfirmModal' -import { SeriesStackCard } from '@client/components/SeriesStackCard' -import { BookListView } from '@client/components/BookListView' -import { BookListRow } from '@client/components/BookListRow' -import { SeriesView } from '@client/components/SeriesView' -import { ReaderPage } from '@client/pages/ReaderPage' -import { QuizReviewPage } from '@client/pages/QuizReviewPage' -import { ReviewProgressPage } from '@client/pages/ReviewProgressPage' -import { SkillDetailPage } from '@client/pages/SkillDetailPage' -import { ProfileUpdatePage } from '@client/pages/ProfileUpdatePage' -import { useBackgroundTasks } from '@client/hooks/useBackgroundTasks' -import { store, persistor, useAppSelector, useAppDispatch, setProviderApiKey, selectHasApiKey, selectFontSize, selectLibraryFilters, selectLibrarySort, selectLibraryView, clearLibraryFilters, setLibraryFilters, selectFunctionModel, selectLastViewedBookId, setLastViewedBookId, selectRunningTasks, DEFAULT_LIBRARY_FILTERS } from '@client/store' -import { PROVIDER_IDS } from '@client/lib/providers' -import { apiUrl } from '@client/lib/api-base' -import { previewEpub as previewEpubApi, confirmImport, type EpubPreview } from '@client/lib/api' -import { isGenerating, isGeneratingToc, isAwaitingTocApproval, isReadable, isComplete } from '@shared/book-status' - -interface Book { - id: string - title: string - subtitle?: string - prompt?: string - chaptersRead: number - totalChapters: number - generatedUpTo: number - status?: string - rating?: number - finalQuizScore?: number - finalQuizTotal?: number - hasCover?: boolean - showTitleOnCover?: boolean - coverUpdatedAt?: string | null - createdAt: string - tags: string[] - series?: string - seriesOrder?: number - sortOrder?: number - imported?: boolean - hasAudiobook?: boolean -} - - -type View = - | { type: 'library' } - | { type: 'creating'; topic: string; details: string; chapterCount: number } - | { type: 'resuming'; bookId: string } - | { type: 'reading'; book: Book } - | { type: 'quiz-review'; book: Book } - | { type: 'review-progress' } - | { type: 'skill-detail'; skillName: string } - | { type: 'profile-update'; bookId: string; bookTitle: string } - | { type: 'series'; seriesName: string } - -// Friendly verbs for the quit-confirmation dialog. Keep aligned with the -// labels used in BackgroundTasksFooter so users see the same wording. -function taskBusyLabel(type: string): string { - switch (type) { - case 'generate-all': return 'Generating chapters' - case 'generate-epub': return 'Exporting EPUB' - case 'generate-cover': return 'Generating cover' - case 'install-audiobook': return 'Setting up narration' - case 'generate-audiobook': return 'Generating audiobook' - default: return type - } -} - -export default function App() { - const [view, setView] = useState({ type: 'library' }) - const [apiBooks, setApiBooks] = useState([]) - const [hasLoaded, setHasLoaded] = useState(false) - const [wizardOpen, setWizardOpen] = useState(false) - const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) - const [contextMenu, setContextMenu] = useState<{ book: Book; x: number; y: number } | null>(null) - const [renameDialog, setRenameDialog] = useState<{ book: Book; title: string; subtitle: string } | null>(null) - const [deleteDialog, setDeleteDialog] = useState<{ book: Book; input: string } | null>(null) - const [resetDialog, setResetDialog] = useState<{ book: Book; input: string } | null>(null) - const [rateDialog, setRateDialog] = useState<{ book: Book; rating: number } | null>(null) - const [overviewBook, setOverviewBook] = useState(null) - const [coverModal, setCoverModal] = useState<{ book: Book } | null>(null) - const [generateAllModal, setGenerateAllModal] = useState<{ taskId: string; book: Book } | null>(null) - const [editTagsDialog, setEditTagsDialog] = useState<{ book: Book } | null>(null) - const [setSeriesDialog, setSetSeriesDialog] = useState<{ book: Book } | null>(null) - const [audiobookExists, setAudiobookExists] = useState>(new Map()) - const [audiobookDownloadModal, setAudiobookDownloadModal] = useState<{ missingBytes: number; missing: { model: boolean; ffmpeg: boolean } } | null>(null) - const [audiobookVoiceModal, setAudiobookVoiceModal] = useState<{ book: Book; mode: 'firstTime' | 'normal' | 'regenerate' } | null>(null) - const [regenerateAudiobookConfirm, setRegenerateAudiobookConfirm] = useState<{ book: Book } | null>(null) - const [pendingAudiobookForBookId, setPendingAudiobookForBookId] = useState(null) - const [seriesContextMenu, setSeriesContextMenu] = useState<{ seriesName: string; books: Book[]; x: number; y: number } | null>(null) - const [renameSeriesDialog, setRenameSeriesDialog] = useState<{ seriesName: string; books: Book[]; newName: string } | null>(null) - const [mutating, setMutating] = useState(false) - const [serverAvailable, setServerAvailable] = useState(true) - const [searchQuery, setSearchQuery] = useState('') - const [fullSearch, setFullSearch] = useState(false) - const [contentSearchResults, setContentSearchResults] = useState>(new Set()) - const [importPreview, setImportPreview] = useState(null) - const [importFileBase64, setImportFileBase64] = useState('') - const [importFilename, setImportFilename] = useState('') - const [importDialogOpen, setImportDialogOpen] = useState(false) - const [isDragOver, setIsDragOver] = useState(false) - const fileInputRef = useRef(null) - const dragCounterRef = useRef(0) - const deferredSearch = useDeferredValue(searchQuery) - const readingPositions = useAppSelector(s => s.readingProgress.positions) - const dispatch = useAppDispatch() - const hasApiKey = useAppSelector(selectHasApiKey) - const fontSize = useAppSelector(selectFontSize) - const libraryFilters = useAppSelector(selectLibraryFilters) - const librarySort = useAppSelector(selectLibrarySort) - const libraryView = useAppSelector(selectLibraryView) - const lastViewedBookId = useAppSelector(selectLastViewedBookId) - const restoredOnceRef = useRef(false) - const { provider: genProvider, model: genModel } = useAppSelector(selectFunctionModel('generation')) - const { provider: quizProvider, model: quizModel } = useAppSelector(selectFunctionModel('quiz')) - - useEffect(() => { - const populated = new Set() - const loadPromises: Promise[] = [] - - if (window.electronAPI) { - // Load API keys from secure storage and POST to server - for (const provider of PROVIDER_IDS) { - loadPromises.push( - window.electronAPI.loadApiKey(provider).then(async key => { - if (key) { - try { - await fetch(apiUrl('/api/settings/api-key'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, apiKey: key }), - }) - } catch { /* server may not be ready */ } - dispatch(setProviderApiKey({ provider, apiKey: key })) - populated.add(provider) - } - }).catch(() => {}) - ) - } - // Also try loading legacy key (no provider suffix) into anthropic - loadPromises.push( - window.electronAPI.loadApiKey().then(async key => { - if (key) { - try { - await fetch(apiUrl('/api/settings/api-key'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: 'anthropic', apiKey: key }), - }) - } catch { /* server may not be ready */ } - dispatch(setProviderApiKey({ provider: 'anthropic', apiKey: key })) - populated.add('anthropic') - } - }).catch(() => {}) - ) - } - - // Belt-and-suspenders: after local IPC attempts (or if not in Electron), - // ask the server which providers it considers configured. If main.ts - // already posted a key whose .enc file the renderer's IPC couldn't read - // (race or transient IPC failure), this still surfaces it in Redux as - // 'configured' so the UI doesn't falsely show "no key". - Promise.all(loadPromises).then(() => - fetch(apiUrl('/api/settings/api-key-status')) - .then(res => res.json()) - .then((status: Record) => { - for (const provider of PROVIDER_IDS) { - if (status[provider] && !populated.has(provider)) { - dispatch(setProviderApiKey({ provider, apiKey: 'configured' })) - } - } - }) - .catch(() => {}) - ) - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - // Health check — disable New Book when server is unreachable - useEffect(() => { - const check = async () => { - try { - const res = await fetch(apiUrl('/api/health')) - setServerAvailable(res.ok) - } catch { - setServerAvailable(false) - } - } - check() - const interval = setInterval(check, 10000) - return () => clearInterval(interval) - }, []) - - // Close context menu on any click or Escape - useEffect(() => { - if (!contextMenu && !seriesContextMenu) return - const close = () => { setContextMenu(null); setSeriesContextMenu(null) } - const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } - window.addEventListener('click', close) - window.addEventListener('keydown', handleKey) - return () => { - window.removeEventListener('click', close) - window.removeEventListener('keydown', handleKey) - } - }, [contextMenu, seriesContextMenu]) - - const handleNewBook = () => { - if (!hasApiKey) { - setApiKeyDialogOpen(true) - } else { - setWizardOpen(true) - } - } - - const fetchBooks = useCallback(async () => { - try { - const res = await fetch(apiUrl('/api/books')) - if (res.ok) { - const books = await res.json() - setApiBooks(prev => { - // Preserve optimistic generating books not yet on server - const generatingBooks = prev.filter(b => isGenerating(b.status) && !books.some((sb: { id: string }) => sb.id === b.id)) - const serverBooks = books.map((b: { id: string; title: string; subtitle?: string; prompt?: string; totalChapters: number; generatedUpTo: number; status?: string; rating?: number; finalQuizScore?: number; finalQuizTotal?: number; hasCover?: boolean; showTitleOnCover?: boolean; coverUpdatedAt?: string | null; createdAt: string; tags: string[]; series?: string; seriesOrder?: number; sortOrder?: number; imported?: boolean; chaptersRead?: number; hasAudiobook?: boolean }) => ({ - id: b.id, - title: b.title, - subtitle: b.subtitle, - prompt: b.prompt, - chaptersRead: b.chaptersRead ?? 0, - totalChapters: b.totalChapters, - generatedUpTo: b.generatedUpTo ?? 0, - status: b.status, - rating: b.rating, - finalQuizScore: b.finalQuizScore, - finalQuizTotal: b.finalQuizTotal, - hasCover: b.hasCover, - showTitleOnCover: b.showTitleOnCover, - coverUpdatedAt: b.coverUpdatedAt, - createdAt: b.createdAt, - tags: b.tags, - series: b.series, - seriesOrder: b.seriesOrder, - sortOrder: b.sortOrder, - imported: b.imported, - hasAudiobook: b.hasAudiobook, - })) - return [...serverBooks, ...generatingBooks] - }) - setHasLoaded(true) - } else { - console.error('[fetchBooks] Server returned', res.status) - setHasLoaded(true) - } - } catch { - setHasLoaded(true) - toast.error('Failed to load books — is the server running?') - } - }, []) - - useEffect(() => { - fetchBooks() - }, [fetchBooks]) - - // Refetch when the window regains focus so external changes (e.g., - // audiobook generated via CLI/MCP, files moved on disk, recovery on - // server restart) show up in the library without a manual reload. - useEffect(() => { - const onFocus = () => { void fetchBooks() } - window.addEventListener('focus', onFocus) - return () => window.removeEventListener('focus', onFocus) - }, [fetchBooks]) - - // Resume last-viewed book on first load - useEffect(() => { - if (!hasLoaded || restoredOnceRef.current) return - restoredOnceRef.current = true - if (!lastViewedBookId) return - const book = apiBooks.find(b => b.id === lastViewedBookId) - if (!book) return - // Only auto-restore into views that won't break for the book's state. - // - toc_review: resume into CreationView's approval flow. - // - reading/complete: open the reader (chapters exist). - // - generating_toc/generating/failed/undefined: stay on the library — - // the reader has no chapter 1 to render, and these statuses either - // reflect an interrupted stream or transient progress. - if (isAwaitingTocApproval(book.status)) { - setView({ type: 'resuming', bookId: book.id }) - } else if (isReadable(book.status)) { - setView({ type: 'reading', book }) - } - }, [hasLoaded, apiBooks, lastViewedBookId]) - - // Safety-net: flush the redux-persist queue on page hide so nothing is lost on quit - useEffect(() => { - const flush = () => { persistor.flush().catch(() => {}) } - window.addEventListener('pagehide', flush) - window.addEventListener('beforeunload', flush) - return () => { - window.removeEventListener('pagehide', flush) - window.removeEventListener('beforeunload', flush) - } - }, []) - - const openBook = useCallback((book: Book) => { - // Same gating as the auto-restore effect — never route a book without - // chapters into the reader. BookCard already disables clicks for the - // active-generation statuses, but this is the defensive backstop. - // Force an immediate persist write so a quick Cmd+Q can't race the debounced write. - if (isAwaitingTocApproval(book.status)) { - dispatch(setLastViewedBookId(book.id)) - persistor.flush().catch(() => {}) - setView({ type: 'resuming', bookId: book.id }) - } else if (isReadable(book.status)) { - dispatch(setLastViewedBookId(book.id)) - persistor.flush().catch(() => {}) - setView({ type: 'reading', book }) - } - // Otherwise (generating_toc, generating, failed): stay on library. - }, [dispatch]) - - // Full-text content search via backend - useEffect(() => { - if (!fullSearch || !deferredSearch.trim()) { - setContentSearchResults(new Set()) - return - } - let cancelled = false - const doSearch = async () => { - try { - const res = await fetch(apiUrl(`/api/books/search?q=${encodeURIComponent(deferredSearch.trim())}&full=true`)) - if (res.ok && !cancelled) { - const data = await res.json() - const results = data.results ?? data - setContentSearchResults(new Set(results.map((r: { bookId: string }) => r.bookId))) - } - } catch { /* ignore */ } - } - doSearch() - return () => { cancelled = true } - }, [fullSearch, deferredSearch]) - - // Connect to background task SSE stream — refresh library on cover generation + auto-download EPUB - const handleEpubExported = useCallback((bookId: string, bookTitle: string) => { - downloadEpub({ id: bookId, title: bookTitle } as Book) - }, []) - useBackgroundTasks({ - onCoverGenerated: fetchBooks, - onEpubExported: handleEpubExported, - onGenerateAllCompleted: fetchBooks, - onAudiobookGenerated: (bookId, bookTitle) => { - // Set immediately so the next menu open shows Play+Regen without - // waiting on the /api/books refetch (which updates the card indicator). - setAudiobookExists(prev => new Map(prev).set(bookId, true)) - void fetchBooks() - toast.success(`Audiobook for "${bookTitle}" is ready!`, { - duration: 12000, - action: { - label: 'Show audiobook', - onClick: () => { - const found = apiBooks.find(b => b.id === bookId) - if (found) void handleShowAudiobook(found) - }, - }, - }) - }, - onAudiobookInstalled: () => { - if (pendingAudiobookForBookId) { - const book = apiBooks.find(b => b.id === pendingAudiobookForBookId) - setPendingAudiobookForBookId(null) - if (book) setAudiobookVoiceModal({ book, mode: 'firstTime' }) - } - }, - onAudiobookTaskFailed: (taskType, bookId) => { - // Install failure: drop the pending-book pointer so a retry doesn't - // chain into the voice modal for a long-stale book id. - if (taskType === 'install-audiobook' && pendingAudiobookForBookId) { - setPendingAudiobookForBookId(null) - } - // Generate failure: cache "no audiobook" since the route handler wipes - // partial artifacts. Refetch books so the card headphones-indicator - // (driven by hasAudiobook) drops if it was set. - if (taskType === 'generate-audiobook') { - setAudiobookExists(prev => new Map(prev).set(bookId, false)) - void fetchBooks() - } - }, - }) - - // Poll for status updates when any book is generating - useEffect(() => { - const hasGenerating = apiBooks.some(b => isGenerating(b.status)) - if (!hasGenerating) return - - const interval = setInterval(fetchBooks, 1000) - return () => clearInterval(interval) - }, [apiBooks, fetchBooks]) - - // Push running-task state to the Electron main process so the window close - // handler can prompt before quitting and accidentally killing a long - // generation. Also wires a web-side beforeunload as a backstop. - const runningTasks = useAppSelector(selectRunningTasks) - const streamingBookIds = apiBooks.filter(b => isGenerating(b.status)).map(b => b.id) - useEffect(() => { - const labels = runningTasks.map(t => `${taskBusyLabel(t.type)} — ${t.bookTitle}`) - // Streaming TOC/chapter writes aren't task-manager tasks; surface them - // alongside so the user sees a complete picture. - for (const bid of streamingBookIds) { - const book = apiBooks.find(b => b.id === bid) - const title = book?.title ?? bid - labels.push(isGeneratingToc(book?.status) - ? `Generating table of contents — ${title}` - : `Generating chapter — ${title}`, - ) - } - const count = labels.length - void window.electronAPI?.setBusyState?.(count, labels) - - if (count === 0) return - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault() - e.returnValue = '' - } - window.addEventListener('beforeunload', handler) - return () => window.removeEventListener('beforeunload', handler) - }, [runningTasks, streamingBookIds, apiBooks]) - - const [pendingCoverPrompt, setPendingCoverPrompt] = useState(null) - - const handleCreate = (topic: string, details: string, chapterCount: number, coverPrompt?: string) => { - setPendingCoverPrompt(coverPrompt ?? null) - setView({ type: 'creating', topic, details, chapterCount }) - } - - const handleCreationComplete = async (bookId: string) => { - // Fire cover generation if opted in during creation - if (pendingCoverPrompt) { - const { provider: imgProvider, model: imgModel } = selectFunctionModel('image')(store.getState()) - fetch(apiUrl(`/api/books/${bookId}/cover/generate`), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: pendingCoverPrompt, provider: imgProvider, model: imgModel }), - }).catch(() => {}) // fire-and-forget - setPendingCoverPrompt(null) - } - // Navigate straight into the reader at chapter 1 — going back to the - // library after the user just sat through TOC + ch.1 generation is a dead-end. - try { - const res = await fetch(apiUrl(`/api/books/${bookId}`)) - if (res.ok) { - const book = await res.json() as Book - dispatch(setLastViewedBookId(bookId)) - persistor.flush().catch(() => {}) - setView({ type: 'reading', book }) - fetchBooks() - return - } - } catch { - // Fall through to library if the fetch fails - } - fetchBooks() - setView({ type: 'library' }) - } - - const handleCreationCancel = async () => { - // Find the candidate book from local state — but local state can lag - // behind the server (the 1s polling stops once status flips to - // toc_review, so a book that just finished TOC generation may still - // appear as generating_toc locally). Re-check the server before - // deleting so we don't blow away a book that has already advanced - // out of the cancellable window. - const candidate = apiBooks.find(b => isGenerating(b.status)) - if (candidate) { - try { - const res = await fetch(apiUrl(`/api/books/${candidate.id}`)) - if (res.ok) { - const fresh = await res.json() - if (isGenerating(fresh.status)) { - fetch(apiUrl(`/api/books/${candidate.id}`), { method: 'DELETE' }).catch(() => {}) - // Remove optimistic book immediately so it doesn't persist as a phantom - setApiBooks(prev => prev.filter(b => b.id !== candidate.id)) - } - } - } catch { - // If the status check fails, err on the side of NOT deleting — - // the user can clean up manually rather than lose work to a flaky network. - } - } - fetchBooks() - setView({ type: 'library' }) - } - - const handleBookCreated = useCallback((bookId: string, title: string, totalChapters?: number) => { - // Point lastViewedBookId at the new book so a refresh during creation - // restores to this book (auto-restore + routing gate will then send a - // toc_review book to the resume view rather than someone's old book). - dispatch(setLastViewedBookId(bookId)) - // Optimistically add the book to the library so it's visible during creation - setApiBooks(prev => { - if (prev.some(b => b.id === bookId)) return prev - return [...prev, { - id: bookId, - title, - chaptersRead: 0, - totalChapters: totalChapters ?? 0, - generatedUpTo: 0, - status: 'generating_toc', - createdAt: new Date().toISOString(), - tags: [], - }] - }) - }, [dispatch]) - - const handleGenerateAll = async (book: Book) => { - try { - const res = await fetch(apiUrl(`/api/books/${book.id}/generate-all`), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: genModel, provider: genProvider, quizModel, quizProvider }), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Failed' })) - throw new Error(err.error) - } - const { taskId } = await res.json() - setGenerateAllModal({ taskId, book }) - } catch (err) { - toast.error('Failed to start generation: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleExportEpub = async (book: Book) => { - try { - const res = await fetch(apiUrl(`/api/books/${book.id}/export-epub`), { - method: 'POST', - body: JSON.stringify({}), - headers: { 'Content-Type': 'application/json' }, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Failed' })) - throw new Error(err.error) - } - const data = await res.json() - if (data.cached) { - // Direct download - await downloadEpub(book) - } else { - // Background task created — will auto-download on completion - toast.success('EPUB export started — check background tasks') - } - } catch (err) { - toast.error('Failed to export EPUB: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const downloadEpub = async (book: Book) => { - try { - const res = await fetch(apiUrl(`/api/books/${book.id}/export-epub`)) - if (!res.ok) throw new Error('Download failed') - const blob = await res.blob() - const filename = `${book.title.replace(/[^a-zA-Z0-9 ]/g, '')}.epub` - - if (window.electronAPI?.saveFile) { - const buffer = await blob.arrayBuffer() - const base64 = btoa(new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), '')) - await window.electronAPI.saveFile(filename, base64) - } else { - // Web fallback - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) - } - } catch { - toast.error('Failed to download EPUB') - } - } - - const checkAudiobookExists = async (bookId: string) => { - if (audiobookExists.has(bookId)) return - try { - const res = await fetch(apiUrl(`/api/books/${bookId}/audiobook`)) - if (!res.ok) return - const data = await res.json() - setAudiobookExists(prev => new Map(prev).set(bookId, data.exists)) - // Keep the card's hasAudiobook indicator in sync. If the server says - // "exists" but our cached book row says otherwise (e.g., the audiobook - // was generated outside this React session), refetch the books list. - const book = apiBooks.find(b => b.id === bookId) - if (book && !!book.hasAudiobook !== data.exists) { - void fetchBooks() - } - } catch { /* swallow */ } - } - - const handleGenerateAudiobook = async (book: Book) => { - try { - const statusRes = await fetch(apiUrl('/api/audiobook/status')) - if (!statusRes.ok) throw new Error('Failed to check audiobook engine status') - const status = await statusRes.json() as { installed: boolean; missing: { model: boolean; ffmpeg: boolean }; downloadSize: number } - if (status.installed) { - setAudiobookVoiceModal({ book, mode: 'normal' }) - } else { - setPendingAudiobookForBookId(book.id) - setAudiobookDownloadModal({ missingBytes: status.downloadSize, missing: status.missing }) - } - } catch (err) { - toast.error('Failed to check audiobook engine: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleConfirmDownload = async () => { - try { - const res = await fetch(apiUrl('/api/audiobook/install'), { method: 'POST' }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || 'Install failed to start') - } - toast.success("Setting up narration… we'll let you know when it's ready.") - setAudiobookDownloadModal(null) - } catch (err) { - toast.error('Failed to start install: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleShowAudiobook = async (book: Book) => { - try { - const res = await fetch(apiUrl(`/api/books/${book.id}/audiobook/reveal`), { method: 'POST' }) - if (!res.ok) throw new Error('Audiobook not found') - const { path, revealed } = await res.json() - // Server-side reveal (open -R / explorer /select) is the primary path - // — works regardless of whether the renderer has Electron IPC wired. - if (revealed) return - // Backup: Electron IPC if we happen to be in the desktop app. - if (window.electronAPI?.showInFinder) { - const ok = await window.electronAPI.showInFinder(path) - if (ok) return - } - // Last resort: copy path so the user can paste into Finder's Go-to. - try { - await navigator.clipboard.writeText(path) - toast.success('Audiobook path copied to clipboard', { - description: 'Open Finder → Go → Go to Folder (⌘⇧G), then paste.', - duration: 10000, - }) - } catch { - toast.success(`Audiobook saved to: ${path}`, { duration: 12000 }) - } - } catch (err) { - toast.error('Failed to reveal audiobook: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleRename = async () => { - if (!renameDialog) return - const trimmed = renameDialog.title.trim() - if (!trimmed) return - setMutating(true) - try { - const res = await fetch(apiUrl(`/api/books/${renameDialog.book.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: trimmed, subtitle: renameDialog.subtitle.trim() || undefined }), - }) - if (res.ok) await fetchBooks() - else toast.error('Failed to rename book') - } catch { - toast.error('Failed to rename book — server unreachable') - } finally { - setMutating(false) - } - setRenameDialog(null) - } - - const handleRenameSeries = async () => { - if (!renameSeriesDialog) return - const trimmed = renameSeriesDialog.newName.trim() - if (!trimmed) return - setMutating(true) - try { - await Promise.all( - renameSeriesDialog.books.map(book => - fetch(apiUrl(`/api/books/${book.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ series: trimmed }), - }) - ) - ) - await fetchBooks() - } catch { - toast.error('Failed to rename series — server unreachable') - } finally { - setMutating(false) - } - setRenameSeriesDialog(null) - } - - const handleDelete = async () => { - if (!deleteDialog || deleteDialog.input.toLowerCase() !== 'delete') return - setMutating(true) - try { - const res = await fetch(apiUrl(`/api/books/${deleteDialog.book.id}`), { - method: 'DELETE', - }) - if (res.ok) await fetchBooks() - else toast.error('Failed to delete book') - } catch { - toast.error('Failed to delete book — server unreachable') - } finally { - setMutating(false) - } - setDeleteDialog(null) - } - - const handleReset = async () => { - if (!resetDialog || resetDialog.input.toLowerCase() !== 'reset') return - setMutating(true) - try { - const res = await fetch(apiUrl(`/api/books/${resetDialog.book.id}/reset`), { - method: 'POST', - }) - if (res.ok) await fetchBooks() - else toast.error('Failed to reset book') - } catch { - toast.error('Failed to reset book — server unreachable') - } finally { - setMutating(false) - } - setResetDialog(null) - } - - const handleSaveTags = async (bookId: string, tags: string[]) => { - try { - const res = await fetch(apiUrl(`/api/books/${bookId}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tags }), - }) - if (res.ok) await fetchBooks() - else toast.error('Failed to save tags') - } catch { - toast.error('Failed to save tags -- server unreachable') - } - setEditTagsDialog(null) - } - - const handleSaveSeries = async (bookId: string, series: string | null, seriesOrder: number | null) => { - try { - const res = await fetch(apiUrl(`/api/books/${bookId}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ series, seriesOrder }), - }) - if (res.ok) await fetchBooks() - else toast.error('Failed to save series') - } catch { - toast.error('Failed to save series -- server unreachable') - } - setSetSeriesDialog(null) - } - - // --- EPUB Import --- - - const readFileAsBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = reader.result as string - // Strip data URL prefix: "data:application/epub+zip;base64,..." - const base64 = result.includes(',') ? result.split(',')[1] : result - resolve(base64) - } - reader.onerror = () => reject(new Error('Failed to read file')) - reader.readAsDataURL(file) - }) - } - - const handleImportFile = async (file: File) => { - if (!file.name.toLowerCase().endsWith('.epub')) { - toast.error('Only .epub files are supported') - return - } - try { - const base64 = await readFileAsBase64(file) - setImportFileBase64(base64) - setImportFilename(file.name) - - const preview = await previewEpubApi(base64, file.name) - setImportPreview(preview) - setImportDialogOpen(true) - } catch (err) { - toast.error('Failed to preview EPUB: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleImportConfirm = async (tags: string[], series: string | null, seriesOrder: number | null) => { - try { - await confirmImport( - importFileBase64, - importFilename, - tags.length > 0 ? tags : undefined, - series ?? undefined, - seriesOrder ?? undefined, - ) - setImportDialogOpen(false) - setImportPreview(null) - setImportFileBase64('') - setImportFilename('') - toast.success('Book imported successfully') - await fetchBooks() - } catch (err) { - toast.error('Failed to import EPUB: ' + (err instanceof Error ? err.message : 'Unknown error')) - } - } - - const handleFileInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) handleImportFile(file) - // Reset file input so the same file can be selected again - e.target.value = '' - } - - const [activeDragId, setActiveDragId] = useState(null) - - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveDragId(String(event.active.id)) - }, []) - - // Track the previous sort field to detect transitions to manual mode - const prevSortFieldRef = useRef(librarySort.field) - - // Initialize sortOrder on first switch to manual mode - useEffect(() => { - const wasManual = prevSortFieldRef.current === 'manual' - prevSortFieldRef.current = librarySort.field - - if (librarySort.field !== 'manual' || wasManual) return - // Switching to manual — assign integer sortOrders if books don't have them yet - const needsInit = apiBooks.some(b => b.sortOrder == null) - if (!needsInit) return - - // Use the current display order (filteredBooks would be ideal, but apiBooks is fine as a base) - const booksToInit = [...apiBooks] - // They're in whatever order they were before — assign integers - const patches = booksToInit.map((book, index) => { - if (book.sortOrder != null) return null - return fetch(apiUrl(`/api/books/${book.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sortOrder: index }), - }) - }).filter(Boolean) - - if (patches.length > 0) { - Promise.all(patches).then(() => fetchBooks()).catch(() => {}) - } - }, [librarySort.field, apiBooks, fetchBooks]) - - const apiBookIds = useMemo(() => new Set(apiBooks.map(b => b.id)), [apiBooks]) - const allBooks = apiBooks - - const classifyBook = useCallback((book: Book): 'finished' | 'in-progress' | 'not-started' => { - if (isComplete(book.status)) return 'finished' - if (readingPositions[book.id] != null) return 'in-progress' - return 'not-started' - }, [readingPositions]) - - // Compute allTags from all books - const allTags = useMemo(() => { - const tagSet = new Set() - for (const book of allBooks) { - for (const tag of book.tags) tagSet.add(tag) - } - return [...tagSet].sort() - }, [allBooks]) - - // Compute all series names from all books - const allSeriesNames = useMemo(() => { - const seriesSet = new Set() - for (const book of allBooks) { - if (book.series) seriesSet.add(book.series) - } - return [...seriesSet].sort() - }, [allBooks]) - - const { filteredBooks, searchResultCount } = useMemo(() => { - const bookClasses = new Map(allBooks.map(b => [b.id, classifyBook(b)])) - - // --- Filter logic --- - let filtered = [...allBooks] - - // Status filter - if (libraryFilters.status === 'unfinished') { - filtered = filtered.filter(b => bookClasses.get(b.id) !== 'finished') - } else if (libraryFilters.status !== 'all') { - filtered = filtered.filter(b => bookClasses.get(b.id) === libraryFilters.status) - } - - // Tags filter (OR logic) - if (libraryFilters.tags.length > 0) { - filtered = filtered.filter(b => - b.tags.some(tag => libraryFilters.tags.includes(tag)) - ) - } - - // Rating filter - if (libraryFilters.ratingMin != null) { - filtered = filtered.filter(b => - (b.rating ?? 0) >= libraryFilters.ratingMin! - ) - } - - // Date preset filter - if (libraryFilters.datePreset !== 'any') { - const now = Date.now() - const days = libraryFilters.datePreset === 'week' ? 7 - : libraryFilters.datePreset === 'month' ? 30 - : 90 // 3months - const cutoff = now - days * 24 * 60 * 60 * 1000 - filtered = filtered.filter(b => new Date(b.createdAt).getTime() >= cutoff) - } - - // Client-side search filtering (title + subtitle + optional content search results) - const query = deferredSearch.trim().toLowerCase() - if (query) { - filtered = filtered.filter(b => - b.title.toLowerCase().includes(query) || - (b.subtitle?.toLowerCase().includes(query) ?? false) || - (fullSearch && contentSearchResults.has(b.id)) - ) - } - - // --- Sort logic --- - const dir = librarySort.direction === 'asc' ? 1 : -1 - - const compareFn = (a: Book, b: Book): number => { - switch (librarySort.field) { - case 'date': - return dir * (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) - case 'title': - return dir * a.title.localeCompare(b.title) - case 'rating': { - const ra = a.rating ?? -1 - const rb = b.rating ?? -1 - // Unrated goes last regardless of direction - if (ra < 0 && rb >= 0) return 1 - if (rb < 0 && ra >= 0) return -1 - return dir * (ra - rb) - } - case 'progress': { - const pa = a.totalChapters > 0 - ? ((readingPositions[a.id] != null ? readingPositions[a.id].chapter + 1 : a.chaptersRead) / a.totalChapters) - : 0 - const pb = b.totalChapters > 0 - ? ((readingPositions[b.id] != null ? readingPositions[b.id].chapter + 1 : b.chaptersRead) / b.totalChapters) - : 0 - return dir * (pa - pb) - } - case 'recent': { - const la = readingPositions[a.id]?.lastReadAt ?? '' - const lb = readingPositions[b.id]?.lastReadAt ?? '' - // Never-read goes last regardless of direction - if (!la && lb) return 1 - if (!lb && la) return -1 - return dir * (la < lb ? -1 : la > lb ? 1 : 0) - } - case 'manual': { - const sa = a.sortOrder ?? Number.MAX_SAFE_INTEGER - const sb = b.sortOrder ?? Number.MAX_SAFE_INTEGER - // Undefined sortOrder goes last regardless of direction - if (sa === Number.MAX_SAFE_INTEGER && sb !== Number.MAX_SAFE_INTEGER) return 1 - if (sb === Number.MAX_SAFE_INTEGER && sa !== Number.MAX_SAFE_INTEGER) return -1 - return dir * (sa - sb) - } - default: - return 0 - } - } - - // Group series books together: find lead book position, then insert series members adjacent - const seriesGroups = new Map() - const nonSeries: Book[] = [] - for (const book of filtered) { - if (book.series) { - const group = seriesGroups.get(book.series) ?? [] - group.push(book) - seriesGroups.set(book.series, group) - } else { - nonSeries.push(book) - } - } - - // Sort non-series books - nonSeries.sort(compareFn) - - // Sort within each series group by seriesOrder - for (const group of seriesGroups.values()) { - group.sort((a, b) => (a.seriesOrder ?? 0) - (b.seriesOrder ?? 0)) - } - - if (seriesGroups.size === 0) { - // No series — just return sorted - return { - filteredBooks: nonSeries, - searchResultCount: query ? nonSeries.length : undefined, - } - } - - // Merge: for each series, find where its lead book would rank among nonSeries+leads - // Create a combined list of non-series books + lead books (first in series by seriesOrder) - const leads = new Map() - for (const [series, group] of seriesGroups) { - leads.set(series, group[0]) - } - - const allLeadsAndNonSeries = [...nonSeries, ...leads.values()] - allLeadsAndNonSeries.sort(compareFn) - - // Now expand: replace each lead with the full series group - const sorted: Book[] = [] - const insertedSeries = new Set() - for (const book of allLeadsAndNonSeries) { - if (book.series && !insertedSeries.has(book.series)) { - insertedSeries.add(book.series) - sorted.push(...(seriesGroups.get(book.series) ?? [book])) - } else if (!book.series) { - sorted.push(book) - } - } - - return { - filteredBooks: sorted, - searchResultCount: query ? sorted.length : undefined, - } - }, [allBooks, libraryFilters, librarySort, classifyBook, deferredSearch, readingPositions, fullSearch, contentSearchResults]) - - // Pre-group books by series in a single pass so the grid/list loops don't - // run `filteredBooks.filter(...)` for each series encountered (O(n·s) → - // O(n)). Also gives a stable array reference per series across renders, - // which lets memoized series cards skip work when only unrelated state moves. - const seriesGroups = useMemo(() => { - const groups = new Map() - for (const book of filteredBooks) { - if (!book.series) continue - const list = groups.get(book.series) - if (list) list.push(book) - else groups.set(book.series, [book]) - } - return groups - }, [filteredBooks]) - - // Drag-and-drop handler for manual sort mode - const handleDragEnd = useCallback(async (event: DragEndEvent) => { - setActiveDragId(null) - const { active, over } = event - if (!over || active.id === over.id) return - - // Build the current grid items list (same structure as rendered) - const renderedSeries = new Set() - const items: Array<{ id: string; sortOrder: number }> = [] - - for (const book of filteredBooks) { - if (book.series) { - if (renderedSeries.has(book.series)) continue - renderedSeries.add(book.series) - items.push({ id: `series-${book.series}`, sortOrder: book.sortOrder ?? 0 }) - } else { - items.push({ id: book.id, sortOrder: book.sortOrder ?? 0 }) - } - } - - const oldIndex = items.findIndex(it => it.id === String(active.id)) - const newIndex = items.findIndex(it => it.id === String(over.id)) - if (oldIndex === -1 || newIndex === -1) return - - // Calculate the new sortOrder based on the target position's neighbors - // In desc mode, higher sortOrder = earlier position, so edge fallbacks must be flipped - const isDesc = librarySort.direction === 'desc' - let newSortOrder: number - if (oldIndex < newIndex) { - // Moving forward: place after the item at newIndex - const after = items[newIndex].sortOrder - const next = newIndex + 1 < items.length ? items[newIndex + 1].sortOrder : after + (isDesc ? -2 : 2) - newSortOrder = (after + next) / 2 - } else { - // Moving backward: place before the item at newIndex - const before = items[newIndex].sortOrder - const prev = newIndex - 1 >= 0 ? items[newIndex - 1].sortOrder : before + (isDesc ? 2 : -2) - newSortOrder = (prev + before) / 2 - } - - // Determine which book(s) to PATCH - const draggedItemId = String(active.id) - const bookIdsToPatch: string[] = [] - - if (draggedItemId.startsWith('series-')) { - const sName = draggedItemId.slice(7) - const sBooks = apiBooks.filter(b => b.series === sName) - bookIdsToPatch.push(...sBooks.map(b => b.id)) - } else { - bookIdsToPatch.push(draggedItemId) - } - - // Optimistically update state so the card doesn't jump on release - setApiBooks(prev => prev.map(b => - bookIdsToPatch.includes(b.id) ? { ...b, sortOrder: newSortOrder } : b - )) - - try { - await Promise.all(bookIdsToPatch.map(bookId => - fetch(apiUrl(`/api/books/${bookId}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sortOrder: newSortOrder }), - }) - )) - - // Check if rebalancing is needed — update the item in the items array - const updatedItems = items.map(it => it.id === draggedItemId ? { ...it, sortOrder: newSortOrder } : it) - updatedItems.sort((a, b) => a.sortOrder - b.sortOrder) - let needsRebalance = false - for (let i = 1; i < updatedItems.length; i++) { - if (Math.abs(updatedItems[i].sortOrder - updatedItems[i - 1].sortOrder) < 1e-10) { - needsRebalance = true - break - } - } - - if (needsRebalance) { - const rebalancePatches = updatedItems.map((item, index) => { - if (item.id.startsWith('series-')) { - const sName = item.id.slice(7) - const sBooks = apiBooks.filter(b => b.series === sName) - return sBooks.map(b => - fetch(apiUrl(`/api/books/${b.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sortOrder: index }), - }) - ) - } else { - return [fetch(apiUrl(`/api/books/${item.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sortOrder: index }), - })] - } - }).flat() - - await Promise.all(rebalancePatches) - } - - // Background sync — no need to await since we already updated optimistically - fetchBooks() - } catch { - toast.error('Failed to reorder — server unreachable') - fetchBooks() // Revert optimistic update on failure - } - }, [filteredBooks, apiBooks, fetchBooks, librarySort.direction]) - - // Compute active filter chips for display - const activeFilterChips = useMemo(() => { - const chips: Array<{ key: string; label: string; onRemove: () => void }> = [] - if (libraryFilters.status !== DEFAULT_LIBRARY_FILTERS.status) { - const labels: Record = { - 'in-progress': 'In Progress', - 'not-started': 'Not Started', - 'finished': 'Finished', - 'unfinished': 'Unfinished', - } - chips.push({ - key: 'status', - label: `Status: ${labels[libraryFilters.status] ?? libraryFilters.status}`, - onRemove: () => dispatch(setLibraryFilters({ status: DEFAULT_LIBRARY_FILTERS.status })), - }) - } - for (const tag of libraryFilters.tags) { - chips.push({ - key: `tag-${tag}`, - label: `Tag: ${tag}`, - onRemove: () => dispatch(setLibraryFilters({ tags: libraryFilters.tags.filter(t => t !== tag) })), - }) - } - if (libraryFilters.ratingMin != null) { - chips.push({ - key: 'rating', - label: `Rating: ${'★'.repeat(libraryFilters.ratingMin)}${libraryFilters.ratingMin < 5 ? '+' : ''}`, - onRemove: () => dispatch(setLibraryFilters({ ratingMin: DEFAULT_LIBRARY_FILTERS.ratingMin })), - }) - } - if (libraryFilters.datePreset !== DEFAULT_LIBRARY_FILTERS.datePreset) { - const labels: Record = { - week: 'Last week', - month: 'Last month', - '3months': 'Last 3 months', - } - chips.push({ - key: 'date', - label: `Created: ${labels[libraryFilters.datePreset] ?? libraryFilters.datePreset}`, - onRemove: () => dispatch(setLibraryFilters({ datePreset: DEFAULT_LIBRARY_FILTERS.datePreset })), - }) - } - return chips - }, [libraryFilters, dispatch]) - - // --- Shared render helpers for context menu & dialogs --- - const renderContextMenu = () => contextMenu && ( -
{ - if (!el) return - const rect = el.getBoundingClientRect() - const vw = window.innerWidth - const vh = window.innerHeight - let x = contextMenu.x - let y = contextMenu.y - if (x + rect.width > vw - 8) x = contextMenu.x - rect.width - if (y + rect.height > vh - 8) y = contextMenu.y - rect.height - if (x < 8) x = 8 - if (y < 8) y = 8 - el.style.left = `${x}px` - el.style.top = `${y}px` - }} - className="fixed z-50 w-fit rounded-lg border border-border-default/50 bg-surface-base/95 backdrop-blur-md py-1 shadow-lg" - style={{ left: -9999, top: -9999 }} - onClick={e => e.stopPropagation()} - > - {/* Edit group */} - - - - -
- {/* View group */} - - -
- {/* Actions group */} - - - - {contextMenu.book.generatedUpTo < contextMenu.book.totalChapters ? ( - - ) : (audiobookExists.get(contextMenu.book.id) === true || contextMenu.book.hasAudiobook === true) ? ( - <> - - - - ) : ( - - )} -
- {/* Danger group */} - - -
- ) - - const renderSeriesContextMenu = () => seriesContextMenu && ( -
{ - if (!el) return - const rect = el.getBoundingClientRect() - const vw = window.innerWidth - const vh = window.innerHeight - let x = seriesContextMenu.x - let y = seriesContextMenu.y - if (x + rect.width > vw - 8) x = seriesContextMenu.x - rect.width - if (y + rect.height > vh - 8) y = seriesContextMenu.y - rect.height - if (x < 8) x = 8 - if (y < 8) y = 8 - el.style.left = `${x}px` - el.style.top = `${y}px` - }} - className="fixed z-50 w-fit rounded-lg border border-border-default/50 bg-surface-base/95 backdrop-blur-md py-1 shadow-lg" - style={{ left: -9999, top: -9999 }} - onClick={e => e.stopPropagation()} - > - -
- ) - - const renderDialogs = () => ( - <> - {/* Rename dialog */} - { if (!open) setRenameDialog(null) }}> - - - Rename Book - -
-
- - setRenameDialog(prev => prev ? { ...prev, title: e.target.value } : null)} - onKeyDown={e => e.key === 'Enter' && handleRename()} - className="h-9 w-full rounded-lg border border-border-default bg-surface-raised px-3 text-sm text-content-primary outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20" - autoFocus - /> -
-
- - setRenameDialog(prev => prev ? { ...prev, subtitle: e.target.value } : null)} - onKeyDown={e => e.key === 'Enter' && handleRename()} - placeholder="Optional subtitle" - className="h-9 w-full rounded-lg border border-border-default bg-surface-raised px-3 text-sm text-content-primary placeholder:text-content-muted/50 outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20" - /> -
-
- - - - -
-
- - {/* Rename Series dialog */} - { if (!open) setRenameSeriesDialog(null) }}> - - - Rename Series - - This will update the series name on {renameSeriesDialog?.books.length ?? 0} {(renameSeriesDialog?.books.length ?? 0) === 1 ? 'book' : 'books'}. - - -
- - setRenameSeriesDialog(prev => prev ? { ...prev, newName: e.target.value } : null)} - onKeyDown={e => e.key === 'Enter' && handleRenameSeries()} - className="h-9 w-full rounded-lg border border-border-default bg-surface-raised px-3 text-sm text-content-primary outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20" - autoFocus - /> -
- - - - -
-
- - {/* Delete confirmation dialog */} - { if (!open) setDeleteDialog(null) }}> - - - Delete Book - - Are you sure you want to delete “{deleteDialog?.book.title}”? Type delete to confirm. - - - setDeleteDialog(prev => prev ? { ...prev, input: e.target.value } : null)} - onKeyDown={e => e.key === 'Enter' && deleteDialog?.input.toLowerCase() === 'delete' && handleDelete()} - placeholder="delete" - className="h-9 rounded-lg border border-border-default bg-surface-raised px-3 text-sm text-content-primary placeholder:text-content-muted/50 outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20" - autoFocus - autoCapitalize="off" - /> - - - - - - - - { if (!open) setResetDialog(null) }}> - - - Reset Book - - Are you sure you want to reset “{resetDialog?.book.title}”? This permanently clears your reading progress, rating, feedback, and quiz answers. The chapters and table of contents will remain. Type reset to confirm. - - - setResetDialog(prev => prev ? { ...prev, input: e.target.value } : null)} - onKeyDown={e => e.key === 'Enter' && resetDialog?.input.toLowerCase() === 'reset' && handleReset()} - placeholder="reset" - className="h-9 rounded-lg border border-border-default bg-surface-raised px-3 text-sm text-content-primary placeholder:text-content-muted/50 outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20" - autoFocus - autoCapitalize="off" - /> - - - - - - - - {/* Rate dialog */} - { if (!open) setRateDialog(null) }}> - - - Rate Book - {rateDialog?.book.title} - -
- setRateDialog(prev => prev ? { ...prev, rating: val } : null)} - size="lg" - /> - {rateDialog && rateDialog.book.rating != null && rateDialog.book.rating > 0 && ( - - )} -
- - - - -
-
- - {/* Edit Tags dialog */} - {editTagsDialog && ( - { if (!open) setEditTagsDialog(null) }} - bookId={editTagsDialog.book.id} - currentTags={editTagsDialog.book.tags} - allTags={allTags} - onSave={handleSaveTags} - /> - )} - - {/* Set Series dialog */} - {setSeriesDialog && ( - { if (!open) setSetSeriesDialog(null) }} - bookId={setSeriesDialog.book.id} - currentSeries={setSeriesDialog.book.series} - currentSeriesOrder={setSeriesDialog.book.seriesOrder} - allSeriesNames={allSeriesNames} - onSave={handleSaveSeries} - /> - )} - - {/* Book overview modal */} - { if (!open) setOverviewBook(null) }} - book={overviewBook ?? { id: '', title: '', totalChapters: 0 }} - /> - - {/* Cover generation modal */} - {coverModal && ( - { if (!open) setCoverModal(null) }} - bookId={coverModal.book.id} - bookTitle={coverModal.book.title} - bookTopic={coverModal.book.prompt ?? coverModal.book.title} - hasCover={coverModal.book.hasCover} - showTitleOnCover={coverModal.book.showTitleOnCover} - onCoverChanged={fetchBooks} - /> - )} - - {/* Generate all modal */} - {generateAllModal && ( - { - if (!open) { - setGenerateAllModal(null) - fetchBooks() - } - }} - taskId={generateAllModal.taskId} - bookTitle={generateAllModal.book.title} - totalChapters={generateAllModal.book.totalChapters} - /> - )} - - {/* Audiobook download modal */} - {audiobookDownloadModal && ( - { if (!open) { setAudiobookDownloadModal(null); setPendingAudiobookForBookId(null) } }} - missing={audiobookDownloadModal.missing} - missingBytes={audiobookDownloadModal.missingBytes} - onConfirm={handleConfirmDownload} - /> - )} - - {/* Audiobook voice modal */} - {audiobookVoiceModal && ( - { if (!open) setAudiobookVoiceModal(null) }} - bookId={audiobookVoiceModal.book.id} - bookTitle={audiobookVoiceModal.book.title} - mode={audiobookVoiceModal.mode} - /> - )} - - {/* Audiobook regenerate confirm modal */} - {regenerateAudiobookConfirm && ( - { if (!open) setRegenerateAudiobookConfirm(null) }} - bookTitle={regenerateAudiobookConfirm.book.title} - onConfirm={() => { - const book = regenerateAudiobookConfirm.book - setRegenerateAudiobookConfirm(null) - setAudiobookVoiceModal({ book, mode: 'regenerate' }) - }} - /> - )} - - ) - - if (view.type === 'creating') { - return ( - - ) - } - - if (view.type === 'resuming') { - return ( - - ) - } - - if (view.type === 'reading') { - return ( - { - dispatch(setLastViewedBookId(null)) - persistor.flush().catch(() => {}) - fetchBooks() - setView({ type: 'library' }) - }} - onQuizReview={() => setView({ type: 'quiz-review', book: view.book })} - onUpdateProfile={() => setView({ type: 'profile-update', bookId: view.book.id, bookTitle: view.book.title })} - /> - ) - } - - if (view.type === 'quiz-review') { - return ( - { fetchBooks(); setView({ type: 'library' }) }} - onBackToReader={() => setView({ type: 'reading', book: view.book })} - /> - ) - } - - if (view.type === 'review-progress') { - return ( - setView({ type: 'library' })} - onSkillClick={(skillName) => setView({ type: 'skill-detail', skillName })} - /> - ) - } - - if (view.type === 'skill-detail') { - return ( - setView({ type: 'review-progress' })} - /> - ) - } - - if (view.type === 'profile-update') { - return ( - { fetchBooks(); setView({ type: 'library' }) }} - /> - ) - } - - if (view.type === 'series') { - const seriesBooks = allBooks.filter(b => b.series === view.seriesName) - return ( - <> - openBook(book)} - onBack={() => { fetchBooks(); setView({ type: 'library' }) }} - onContextMenu={(book, e) => { - if (apiBookIds.has(book.id)) { - e.preventDefault() - checkAudiobookExists(book.id) - setContextMenu({ book, x: e.clientX, y: e.clientY }) - } - }} - /> - {renderContextMenu()} - {renderDialogs()} - - ) - } - - return ( -
- - {/* Header */} -
- - Tutor - - -
- - - - - setApiKeyDialogOpen(false)} - onReviewProgress={() => setView({ type: 'review-progress' })} - /> -
-
- - {/* Library toolbar */} - {allBooks.length > 0 && ( - - )} - - {/* Filter chips row */} - {activeFilterChips.length > 0 && ( -
-
- {activeFilterChips.map(chip => ( - - {chip.label} - - - ))} - -
-
- )} - - {/* Library grid */} -
{ - e.preventDefault() - e.stopPropagation() - dragCounterRef.current++ - if (e.dataTransfer.types.includes('Files')) { - setIsDragOver(true) - } - }} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onDragLeave={(e) => { - e.preventDefault() - e.stopPropagation() - dragCounterRef.current-- - if (dragCounterRef.current <= 0) { - dragCounterRef.current = 0 - setIsDragOver(false) - } - }} - onDrop={(e) => { - e.preventDefault() - e.stopPropagation() - dragCounterRef.current = 0 - setIsDragOver(false) - const file = e.dataTransfer.files?.[0] - if (file) handleImportFile(file) - }} - > - {/* Drop zone overlay */} - {isDragOver && ( -
-
- -

Drop EPUB to import

-
-
- )} -
- {hasLoaded && allBooks.length === 0 ? ( -
- -

No books yet

-

Create your first book to start learning.

- -
- ) : filteredBooks.length === 0 ? ( -
- -

- {deferredSearch ? 'No books match your search.' : 'No books match this filter.'} -

-
- ) : libraryView === 'list' ? ( - (() => { - // Build list items: group series, keep non-series as individual rows - const renderedSeries = new Set() - const listItems: Array< - | { type: 'book'; book: Book; chaptersRead: number } - | { type: 'series'; seriesName: string; bookCount: number; books: Array<{ book: Book; chaptersRead: number }> } - > = [] - - for (const book of filteredBooks) { - if (book.series) { - if (renderedSeries.has(book.series)) continue - renderedSeries.add(book.series) - - const seriesBooks = seriesGroups.get(book.series) ?? [] - listItems.push({ - type: 'series', - seriesName: book.series, - bookCount: seriesBooks.length, - books: seriesBooks.map(b => { - if (isComplete(b.status)) return { book: b, chaptersRead: b.totalChapters } - const pos = readingPositions[b.id] - return { book: b, chaptersRead: Math.max(b.chaptersRead, pos != null ? pos.chapter + 1 : 0) } - }), - }) - } else { - const pos = readingPositions[book.id] - listItems.push({ - type: 'book', - book, - chaptersRead: isComplete(book.status) ? book.totalChapters : Math.max(book.chaptersRead, pos != null ? pos.chapter + 1 : 0), - }) - } - } - - const isManual = librarySort.field === 'manual' - const listView = ( - openBook(book)} - onSeriesClick={(seriesName) => setView({ type: 'series', seriesName })} - onContextMenu={(book, e) => { - if (apiBookIds.has(book.id)) { - e.preventDefault() - checkAudiobookExists(book.id) - setContextMenu({ book, x: e.clientX, y: e.clientY }) - } - }} - onSeriesContextMenu={(seriesName, books, e) => { - e.preventDefault() - setSeriesContextMenu({ seriesName, books, x: e.clientX, y: e.clientY }) - }} - /> - ) - - if (isManual) { - const listItemIds = listItems.map(item => - item.type === 'series' ? `series-${item.seriesName}` : item.book.id - ) - return ( - setActiveDragId(null)}> - - {listView} - - - {activeDragId && (() => { - if (activeDragId.startsWith('series-')) { - const seriesName = activeDragId.slice(7) - const seriesBooks = seriesGroups.get(seriesName) ?? [] - return ( -
-
- {[...Array(Math.min(seriesBooks.length, 3))].map((_, i) => ( -
- ))} -
- {seriesName} - {seriesBooks.length} books -
- ) - } - const book = filteredBooks.find(b => b.id === activeDragId) - if (!book) return null - const pos = readingPositions[book.id] - const chaptersRead = Math.max(book.chaptersRead, pos != null ? pos.chapter + 1 : 0) - return ( -
- {}} /> -
- ) - })()} - - - ) - } - - return listView - })() - ) : ( - (() => { - // Build grid items: collapse series into stack cards, keep non-series as individual cards - const renderedSeries = new Set() - const gridItemIds: string[] = [] - const gridElements: React.ReactNode[] = [] - const isManual = librarySort.field === 'manual' - - for (const book of filteredBooks) { - if (book.series) { - if (renderedSeries.has(book.series)) continue - renderedSeries.add(book.series) - - const seriesBooks = seriesGroups.get(book.series) ?? [] - const totalChapters = seriesBooks.reduce((s, b) => s + b.totalChapters, 0) - const chaptersRead = seriesBooks.reduce((s, b) => { - if (isComplete(b.status)) return s + b.totalChapters - const pos = readingPositions[b.id] - return s + Math.max(b.chaptersRead, pos != null ? pos.chapter + 1 : 0) - }, 0) - - const itemId = `series-${book.series}` - gridItemIds.push(itemId) - - const seriesCtxMenu = (e: React.MouseEvent) => { - e.preventDefault() - setSeriesContextMenu({ seriesName: book.series!, books: seriesBooks, x: e.clientX, y: e.clientY }) - } - - if (isManual) { - gridElements.push( - setView({ type: 'series', seriesName: book.series! })} - onContextMenu={seriesCtxMenu} - /> - ) - } else { - gridElements.push( - setView({ type: 'series', seriesName: book.series! })} - onContextMenu={seriesCtxMenu} - /> - ) - } - } else { - const pos = readingPositions[book.id] - const chaptersRead = isComplete(book.status) - ? book.totalChapters - : Math.max(book.chaptersRead, pos != null ? pos.chapter + 1 : 0) - gridItemIds.push(book.id) - - if (isManual) { - gridElements.push( - openBook(book)} - onContextMenu={apiBookIds.has(book.id) ? (e) => { - e.preventDefault() - checkAudiobookExists(book.id) - setContextMenu({ book, x: e.clientX, y: e.clientY }) - } : undefined} - /> - ) - } else { - gridElements.push( - openBook(book)} - onContextMenu={apiBookIds.has(book.id) ? (e) => { - e.preventDefault() - checkAudiobookExists(book.id) - setContextMenu({ book, x: e.clientX, y: e.clientY }) - } : undefined} - /> - ) - } - } - } - - const gridDiv = ( -
- {gridElements} -
- ) - - if (isManual) { - return ( - - - {gridDiv} - - - ) - } - - return gridDiv - })() - )} -
-
- - {renderContextMenu()} - {renderSeriesContextMenu()} - {renderDialogs()} - - {/* Import EPUB dialog */} - { - setImportDialogOpen(open) - if (!open) { - setImportPreview(null) - setImportFileBase64('') - setImportFilename('') - } - }} - preview={importPreview} - fileBase64={importFileBase64} - filename={importFilename} - allTags={allTags} - allSeriesNames={allSeriesNames} - onConfirm={handleImportConfirm} - /> - - {/* Background tasks footer */} - -
- ) -} diff --git a/client/api/audiobook.test.ts b/client/api/audiobook.test.ts new file mode 100644 index 0000000..5aae33c --- /dev/null +++ b/client/api/audiobook.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { + getBookAudiobook, + generateAudiobook, + getEngineStatus, + installEngine, + listVoices, + revealAudiobook, +} from './audiobook' + +/** + * The narration engine, its voices, and the audiobook generated for each + * book. installEngine gets the most attention here, since a 409 from that + * one endpoint is a success rather than a failure, and that is the one + * behavior in this module that is easy to get backwards. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() +}) + +describe('getBookAudiobook', () => { + it('requests the per-book audiobook status', async () => { + const payload = { exists: true, generatedChapters: [1, 2], manifest: null } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const result = await getBookAudiobook('ada') + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/audiobook') + expect((init as RequestInit).method).toBeUndefined() + expect(result).toEqual(payload) + }) + + it('omits the trace header, since this polls on an interval while generating', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ exists: false, generatedChapters: [], manifest: null }), { status: 200 }), + ) + + await getBookAudiobook('ada') + + const headers = new Headers((fetchSpy.mock.calls[0][1] as RequestInit).headers) + expect(headers.has('X-Trace-Id')).toBe(false) + }) +}) + +describe('generateAudiobook', () => { + it('posts the voice, speed, and remember or replace choices', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ taskId: 't1' }), { status: 200 })) + const body = { voiceId: 'am_michael', speed: 1.1, rememberAsDefault: true, confirmReplace: false } + + const result = await generateAudiobook('ada', body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/audiobook') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ taskId: 't1' }) + }) + + it('throws an ApiError carrying the status and the reason the server gave', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Audiobook already exists', exists: true }), { status: 409 }), + ) + + const failure = await generateAudiobook('ada', {}).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(409) + expect((failure as Error).message).toBe('Audiobook already exists') + }) +}) + +describe('getEngineStatus', () => { + it('requests the narration engine status', async () => { + const payload = { installed: true, missing: { model: false, ffmpeg: false }, downloadSize: 0 } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const result = await getEngineStatus() + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/audiobook/status') + expect((init as RequestInit).method).toBeUndefined() + expect(result).toEqual(payload) + }) +}) + +describe('installEngine', () => { + it('posts to the install endpoint', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ taskId: 't1' }), { status: 200 })) + + await installEngine() + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/audiobook/install') + expect((init as RequestInit).method).toBe('POST') + }) + + it('resolves rather than throwing when the engine is already installed or installing', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Audiobook engine already installed' }), { status: 409 }), + ) + + await expect(installEngine()).resolves.toBeUndefined() + }) + + it('still throws an ApiError for a real failure', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'Disk full' }), { status: 500 })) + + const failure = await installEngine().catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(500) + expect((failure as Error).message).toBe('Disk full') + }) +}) + +describe('listVoices', () => { + it('requests the voice list and unwraps it', async () => { + const voices = [ + { id: 'am_michael', name: 'Michael', language: 'American English', gender: 'Male', grade: 'A' }, + ] + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ voices }), { status: 200 })) + + const result = await listVoices() + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/audiobook/voices') + expect((init as RequestInit).method).toBeUndefined() + expect(result).toEqual(voices) + }) +}) + +describe('revealAudiobook', () => { + it('posts to the reveal endpoint for one book', async () => { + const payload = { path: '/books/ada/audiobook/book.m4b', revealed: true } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const result = await revealAudiobook('ada') + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/audiobook/reveal') + expect((init as RequestInit).method).toBe('POST') + expect(result).toEqual(payload) + }) +}) diff --git a/client/api/audiobook.ts b/client/api/audiobook.ts new file mode 100644 index 0000000..bf0f7fc --- /dev/null +++ b/client/api/audiobook.ts @@ -0,0 +1,88 @@ +import type { z } from 'zod' +import type { GenerateAudiobookBodySchema } from '@shared/contracts' +import type { AudiobookManifest } from '@shared/domain' +import type { AudiobookStatus, VoiceInfo } from '@shared/responses' +import { apiFetch, expectOk, request } from './http' + +/** + * Endpoints for the narration engine, the voices it offers, and the + * audiobook generated for each book. + * + * Request bodies are inferred from the Zod schemas the server validates + * against, so a body this module sends cannot drift from what the route + * accepts. The schemas are imported as types only and compile away, so no + * validator reaches the browser bundle. + */ + +/** The audiobook state for one book, covering whether it exists, how far narration has gotten, and its manifest. */ +export interface BookAudiobookStatus { + exists: boolean + path?: string + generatedChapters: number[] + manifest: AudiobookManifest | null +} + +/** + * Check whether a book has a generated audiobook and how far generation has progressed. + * + * Tracing is switched off because this call runs on a four second interval + * while an audiobook is generating. Adding the trace header would turn a + * CORS-simple GET into a preflighted one, doubling the request count for as + * long as the poll runs. + */ +export async function getBookAudiobook(bookId: string): Promise { + return request(`/api/books/${bookId}/audiobook`, { trace: false }) +} + +/** The voice, speed, and remember or replace choices generateAudiobook sends. */ +export type GenerateAudiobookBody = z.infer + +/** What generateAudiobook resolves with once narration has been queued. */ +export interface GenerateAudiobookResult { + taskId: string +} + +/** Start narrating a book's chapters into a single audiobook file. */ +export async function generateAudiobook( + bookId: string, + body: GenerateAudiobookBody, +): Promise { + return request(`/api/books/${bookId}/audiobook`, { method: 'POST', body }) +} + +/** Check whether the narration engine, meaning the model and ffmpeg, is installed. */ +export async function getEngineStatus(): Promise { + return request('/api/audiobook/status') +} + +/** + * Kick off the narration engine install. + * + * A 409 from the server means the engine is already installed or an install + * is already running. Either outcome is exactly what this call wants, so it + * resolves instead of throwing. request() would turn that response into an + * ApiError, so this goes through apiFetch directly and checks the status by + * hand. + */ +export async function installEngine(): Promise { + const response = await apiFetch('/api/audiobook/install', { method: 'POST' }) + if (response.status === 409) return + await expectOk(response, 'Failed to start the narrator install') +} + +/** List the narrator voices available for the voice picker. */ +export async function listVoices(): Promise { + const { voices } = await request<{ voices: VoiceInfo[] }>('/api/audiobook/voices') + return voices +} + +/** What revealAudiobook resolves with, meaning the file path and whether the OS reveal succeeded. */ +export interface RevealAudiobookResult { + path: string + revealed: boolean +} + +/** Ask the server to reveal a book's audiobook file in Finder or Explorer. */ +export async function revealAudiobook(bookId: string): Promise { + return request(`/api/books/${bookId}/audiobook/reveal`, { method: 'POST' }) +} diff --git a/client/api/books.test.ts b/client/api/books.test.ts new file mode 100644 index 0000000..c3f51d9 --- /dev/null +++ b/client/api/books.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { + listBooks, getBook, updateBook, deleteBook, resetBook, rateBook, + searchBooks, getToc, generateAllChapters, exportEpub, downloadEpub, +} from './books' + +/** + * One test per endpoint pins the method, the resolved URL, and the body this + * module serialises, so a change to any of the three is a deliberate edit + * here rather than a silent drift from what the server expects. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('listBooks', () => { + it('sends a GET to the library list with no trace header', async () => { + fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })) + + await listBooks() + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books') + expect(init.method).toBeUndefined() + expect(new Headers(init.headers).has('X-Trace-Id')).toBe(false) + }) + + it('resolves the augmented book list the server sends', async () => { + const books = [{ id: 'ada', title: 'Ada', hasCover: true }] + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(books), { status: 200 })) + + await expect(listBooks()).resolves.toEqual(books) + }) +}) + +describe('getBook', () => { + it('sends a GET to the book by id', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"id":"ada"}', { status: 200 })) + + await getBook('ada') + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada') + expect(init.method).toBeUndefined() + }) + + it('throws an ApiError carrying the server reason and status', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"Book not found"}', { status: 404 })) + + const failure = await getBook('missing').catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(404) + expect((failure as Error).message).toBe('Book not found') + }) +}) + +describe('updateBook', () => { + it('sends a PATCH with only the changed fields', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await updateBook('ada', { title: 'New Title', tags: ['math'] }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada') + expect(init.method).toBe('PATCH') + expect(init.body).toBe('{"title":"New Title","tags":["math"]}') + }) + + it('sends null to clear a nullable field such as series', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await updateBook('ada', { series: null, seriesOrder: null }) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.body).toBe('{"series":null,"seriesOrder":null}') + }) +}) + +describe('deleteBook', () => { + it('sends a DELETE to the book by id', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await deleteBook('ada') + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada') + expect(init.method).toBe('DELETE') + }) +}) + +describe('resetBook', () => { + it('sends a POST to the reset endpoint', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await resetBook('ada') + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/reset') + expect(init.method).toBe('POST') + }) +}) + +describe('rateBook', () => { + it('sends a PUT with the rating body', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await rateBook('ada', { rating: 4.5, finalQuizScore: 8, finalQuizTotal: 10 }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/rating') + expect(init.method).toBe('PUT') + expect(init.body).toBe('{"rating":4.5,"finalQuizScore":8,"finalQuizTotal":10}') + }) +}) + +describe('searchBooks', () => { + it('sends a GET with the query and the full flag', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"results":[]}', { status: 200 })) + + await searchBooks('mitochondria', true) + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/books/search?q=mitochondria&full=true') + }) + + it('omits the full flag when a title-only search was requested', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"results":[]}', { status: 200 })) + + await searchBooks('mitochondria', false) + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/books/search?q=mitochondria') + }) +}) + +describe('getToc', () => { + it('sends a GET to the table of contents endpoint', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"chapters":[]}', { status: 200 })) + + await getToc('ada') + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/toc') + expect(init.method).toBeUndefined() + }) +}) + +describe('generateAllChapters', () => { + it('sends a POST with the generation settings and resolves the task id', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"taskId":"t1"}', { status: 200 })) + + const result = await generateAllChapters('ada', { + model: 'claude-sonnet-4-6', provider: 'anthropic', quizModel: 'claude-sonnet-4-6', quizProvider: 'anthropic', + }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/generate-all') + expect(init.method).toBe('POST') + expect(init.body).toBe( + '{"model":"claude-sonnet-4-6","provider":"anthropic","quizModel":"claude-sonnet-4-6","quizProvider":"anthropic"}', + ) + expect(result).toEqual({ taskId: 't1' }) + }) +}) + +describe('exportEpub', () => { + it('sends a POST with an empty body', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"cached":true,"path":"/api/books/ada/export-epub"}', { status: 200 })) + + const result = await exportEpub('ada') + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/export-epub') + expect(init.method).toBe('POST') + expect(init.body).toBe('{}') + expect(result.cached).toBe(true) + }) +}) + +describe('downloadEpub', () => { + it('returns the binary body rather than parsing it as JSON', async () => { + const bytes = new Uint8Array([1, 2, 3]) + fetchSpy.mockResolvedValueOnce(new Response(bytes, { status: 200 })) + + const blob = await downloadEpub('ada') + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/books/ada/export-epub') + expect(blob).toBeInstanceOf(Blob) + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes) + }) + + it('throws an ApiError when no EPUB has been generated yet', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"No EPUB file, generate it first"}', { status: 404 })) + + const failure = await downloadEpub('ada').catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(404) + }) +}) diff --git a/client/api/books.ts b/client/api/books.ts new file mode 100644 index 0000000..76fc6fa --- /dev/null +++ b/client/api/books.ts @@ -0,0 +1,96 @@ +import type { z } from 'zod' +import { request, apiFetch, expectOk } from './http' +import type { LibraryBook, BookDetail, SearchResults } from '@shared/responses' +import type { Toc } from '@shared/domain' +import type { PatchBookBodySchema, RatingBodySchema, GenerateNextBodySchema } from '@shared/contracts' + +/** + * The book-level endpoints. This covers the library list, one book's + * metadata, its table of contents, and the actions that act on a whole book + * rather than a single chapter. Chapter-level endpoints live in chapters.ts + * instead. + */ + +/** This type lists the fields updateBook may change. It mirrors PatchBookBodySchema in shared/contracts.ts. */ +export type BookPatch = z.infer + +/** This type is the body rateBook sends. It mirrors RatingBodySchema in shared/contracts.ts. */ +export type BookRating = z.infer + +/** This type is the body generateAllChapters sends. It mirrors GenerateNextBodySchema in shared/contracts.ts. */ +export type GenerateAllBody = z.infer + +/** + * POST /api/books/:id/export-epub answers in one of two shapes. A cached + * export returns the path directly. Otherwise the server has started a + * background task, and this carries that task's id instead. + */ +export interface ExportEpubResult { + cached?: boolean + path?: string + taskId?: string +} + +/** List every book in the library. */ +export function listBooks(): Promise { + // This list is polled every second while any book is generating, so tracing + // stays off here on purpose. The X-Trace-Id header would turn a CORS-simple + // GET into a preflighted one, doubling the request count for as long as + // generation runs. + return request('/api/books', { trace: false }) +} + +/** Fetch one book's metadata plus its current generation status. */ +export function getBook(id: string): Promise { + return request(`/api/books/${id}`) +} + +/** Change a subset of a book's metadata fields. */ +export function updateBook(id: string, patch: BookPatch): Promise { + return request(`/api/books/${id}`, { method: 'PATCH', body: patch }) +} + +/** Delete a book and everything generated for it. */ +export function deleteBook(id: string): Promise { + return request(`/api/books/${id}`, { method: 'DELETE' }) +} + +/** Clear a book's reader interaction, meaning its progress, rating, feedback, and quiz answers, without deleting its content. */ +export function resetBook(id: string): Promise { + return request(`/api/books/${id}/reset`, { method: 'POST' }) +} + +/** Rate a finished book, optionally recording its final quiz score. */ +export function rateBook(id: string, rating: BookRating): Promise { + return request(`/api/books/${id}/rating`, { method: 'PUT', body: rating }) +} + +/** Search titles, and optionally table of contents and chapter text, across the whole library. */ +export function searchBooks(query: string, full: boolean): Promise { + const params = new URLSearchParams({ q: query }) + if (full) params.set('full', 'true') + return request(`/api/books/search?${params}`) +} + +/** Fetch a book's approved table of contents. */ +export function getToc(id: string): Promise { + return request(`/api/books/${id}/toc`) +} + +/** Start generating every remaining chapter as a background task. */ +export function generateAllChapters(id: string, body: GenerateAllBody): Promise<{ taskId: string }> { + return request<{ taskId: string }>(`/api/books/${id}/generate-all`, { method: 'POST', body }) +} + +/** Export a book to EPUB, or start the background task that exports it if no cached copy exists yet. */ +export function exportEpub(id: string): Promise { + return request(`/api/books/${id}/export-epub`, { method: 'POST', body: {} }) +} + +/** Download a book's already exported EPUB file as a binary blob. */ +export async function downloadEpub(id: string): Promise { + // request parses the body as JSON, which would corrupt binary data, so + // this goes one level lower and reads the response itself. + const response = await expectOk(await apiFetch(`/api/books/${id}/export-epub`)) + return response.blob() +} diff --git a/client/api/chapters.test.ts b/client/api/chapters.test.ts new file mode 100644 index 0000000..406702e --- /dev/null +++ b/client/api/chapters.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import type { GenerateChapterEvent } from '@shared/events' +import { + getChapter, saveChapterProgress, submitChapterFeedback, getChapterQuiz, generateFinalQuiz, + streamNextChapter, streamChapterRegeneration, streamGenerationResume, +} from './chapters' + +/** + * One test per endpoint pins the method, the resolved URL, and the body this + * module serialises, so a change to any of the three is a deliberate edit + * here rather than a silent drift from what the server expects. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +/** This response's body arrives in the given pieces rather than all at once, mirroring the helper in sse.test.ts. */ +function chunkedResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(body, init) +} + +describe('getChapter', () => { + it('sends a GET to the chapter by number', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"content":"# Chapter One"}', { status: 200 })) + + await getChapter('ada', 1) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/chapters/1') + expect(init.method).toBeUndefined() + }) + + it('throws an ApiError carrying the server reason and status', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"Chapter 9 out of range (1-3)"}', { status: 400 })) + + const failure = await getChapter('ada', 9).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(400) + expect((failure as Error).message).toBe('Chapter 9 out of range (1-3)') + }) +}) + +describe('saveChapterProgress', () => { + it('sends a PUT with the scroll progress', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await saveChapterProgress('ada', 2, { scroll: 0.5, completed: false }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/progress/2') + expect(init.method).toBe('PUT') + expect(init.body).toBe('{"scroll":0.5,"completed":false}') + }) +}) + +describe('submitChapterFeedback', () => { + it('sends a POST with the feedback and quiz answers', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await submitChapterFeedback('ada', 2, { liked: 'the examples', disliked: '', quizAnswers: [0, 2, 1] }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/chapters/2/feedback') + expect(init.method).toBe('POST') + expect(init.body).toBe('{"liked":"the examples","disliked":"","quizAnswers":[0,2,1]}') + }) +}) + +describe('getChapterQuiz', () => { + it('sends a GET with the model, provider, and quiz length as query params', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"questions":[]}', { status: 200 })) + + await getChapterQuiz('ada', 1, { model: 'claude-sonnet-4-6', provider: 'anthropic', quizLength: 5 }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/chapters/1/quiz?model=claude-sonnet-4-6&provider=anthropic&quizLength=5') + expect(init.method).toBeUndefined() + }) + + it('omits the quiz length when the reader has not set one', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"questions":[]}', { status: 200 })) + + await getChapterQuiz('ada', 1, { model: 'claude-sonnet-4-6', provider: 'anthropic' }) + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/books/ada/chapters/1/quiz?model=claude-sonnet-4-6&provider=anthropic') + }) +}) + +describe('generateFinalQuiz', () => { + it('sends a POST with the quiz model and provider', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"questions":[]}', { status: 200 })) + + await generateFinalQuiz('ada', { model: 'claude-sonnet-4-6', provider: 'anthropic' }) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/final-quiz') + expect(init.method).toBe('POST') + expect(init.body).toBe('{"model":"claude-sonnet-4-6","provider":"anthropic"}') + }) +}) + +describe('streamNextChapter', () => { + it('sends a POST with the generation settings and reports every event', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + 'data: {"type":"chapter","text":"Once upon"}\n', + 'data: {"type":"done","chapterNum":2}\n', + ])) + const events: GenerateChapterEvent[] = [] + + await streamNextChapter('ada', { model: 'claude-sonnet-4-6', provider: 'anthropic' }, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/generate-next') + expect(init.method).toBe('POST') + expect(init.body).toBe('{"model":"claude-sonnet-4-6","provider":"anthropic"}') + expect(events).toEqual([ + { type: 'chapter', text: 'Once upon' }, + { type: 'done', chapterNum: 2 }, + ]) + }) +}) + +describe('streamChapterRegeneration', () => { + it('sends a POST to the chapter regenerate endpoint', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['data: {"type":"done","chapterNum":3}\n'])) + const events: GenerateChapterEvent[] = [] + + await streamChapterRegeneration('ada', 3, { model: 'claude-sonnet-4-6', provider: 'anthropic' }, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/chapters/3/regenerate') + expect(init.method).toBe('POST') + expect(init.body).toBe('{"model":"claude-sonnet-4-6","provider":"anthropic"}') + expect(events).toEqual([{ type: 'done', chapterNum: 3 }]) + }) +}) + +describe('streamGenerationResume', () => { + it('sends a GET carrying the abort signal and reports every event', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['data: {"type":"done","chapterNum":2}\n'])) + const controller = new AbortController() + const events: GenerateChapterEvent[] = [] + + await streamGenerationResume('ada', controller.signal, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/books/ada/generation-stream') + expect(init.method).toBeUndefined() + expect(init.signal).toBe(controller.signal) + expect(events).toEqual([{ type: 'done', chapterNum: 2 }]) + }) +}) diff --git a/client/api/chapters.ts b/client/api/chapters.ts new file mode 100644 index 0000000..433e2e2 --- /dev/null +++ b/client/api/chapters.ts @@ -0,0 +1,91 @@ +import type { z } from 'zod' +import { request } from './http' +import { streamGeneration } from './sse' +import type { Quiz, ChapterProgress } from '@shared/domain' +import type { GenerateChapterEvent } from '@shared/events' +import type { FeedbackBodySchema, GenerateNextBodySchema, FinalQuizBodySchema } from '@shared/contracts' + +/** + * The chapter-level endpoints. This covers one chapter's content and + * progress, its feedback and quiz, and the three streams that write a + * chapter while the reader watches. Book-level endpoints live in books.ts + * instead. + */ + +/** GET .../chapters/:num answers with a chapter's markdown content. */ +export interface ChapterContent { + content: string +} + +/** These are the query parameters getChapterQuiz sends. quizLength is only set once the reader has chosen one. */ +export interface ChapterQuizParams { + model: string + provider: string + quizLength?: number +} + +/** This type is the body submitChapterFeedback sends. It mirrors FeedbackBodySchema in shared/contracts.ts. */ +export type ChapterFeedbackBody = z.infer + +/** This type is the body streamNextChapter and streamChapterRegeneration send. It mirrors GenerateNextBodySchema in shared/contracts.ts. */ +export type GenerateChapterBody = z.infer + +/** This type is the body generateFinalQuiz sends. It mirrors FinalQuizBodySchema in shared/contracts.ts. */ +export type FinalQuizBody = z.infer + +/** Fetch one chapter's markdown content. */ +export function getChapter(bookId: string, num: number): Promise { + return request(`/api/books/${bookId}/chapters/${num}`) +} + +/** Record how far the reader has scrolled into a chapter, and whether they finished it. */ +export function saveChapterProgress(bookId: string, num: number, progress: ChapterProgress): Promise { + return request(`/api/books/${bookId}/progress/${num}`, { method: 'PUT', body: progress }) +} + +/** Submit what the reader liked and disliked about a chapter, plus their quiz answers. */ +export function submitChapterFeedback(bookId: string, num: number, body: ChapterFeedbackBody): Promise { + return request(`/api/books/${bookId}/chapters/${num}/feedback`, { method: 'POST', body }) +} + +/** Fetch a chapter's quiz, generating it on demand if none exists yet. */ +export function getChapterQuiz(bookId: string, num: number, params: ChapterQuizParams): Promise { + const query = new URLSearchParams({ model: params.model, provider: params.provider }) + if (params.quizLength) query.set('quizLength', String(params.quizLength)) + return request(`/api/books/${bookId}/chapters/${num}/quiz?${query}`) +} + +/** Generate the whole-book quiz shown after the final chapter, or fetch the cached one. */ +export function generateFinalQuiz(bookId: string, body: FinalQuizBody): Promise { + return request(`/api/books/${bookId}/final-quiz`, { method: 'POST', body }) +} + +/** Stream the next chapter as the server generates it. */ +export function streamNextChapter( + bookId: string, + body: GenerateChapterBody, + onEvent: (event: GenerateChapterEvent) => void, +): Promise { + return streamGeneration(`/api/books/${bookId}/generate-next`, { method: 'POST', body }, onEvent) +} + +/** Stream a chapter being regenerated in place. */ +export function streamChapterRegeneration( + bookId: string, + num: number, + body: GenerateChapterBody, + onEvent: (event: GenerateChapterEvent) => void, +): Promise { + return streamGeneration(`/api/books/${bookId}/chapters/${num}/regenerate`, { method: 'POST', body }, onEvent) +} + +/** Reconnect to a chapter generation already in progress, picking up wherever it is. */ +export function streamGenerationResume( + bookId: string, + signal: AbortSignal, + onEvent: (event: GenerateChapterEvent) => void, +): Promise { + // The reader aborts this signal on unmount, since it is a long-lived + // connection that must be torn down rather than merely ignored. + return streamGeneration(`/api/books/${bookId}/generation-stream`, { signal }, onEvent) +} diff --git a/client/api/chat.test.ts b/client/api/chat.test.ts new file mode 100644 index 0000000..fc3698d --- /dev/null +++ b/client/api/chat.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { streamChat, type StreamChatParams } from './chat' + +/** + * streamChat is a thin wrapper over streamText that fixes the path and the + * body shape for the inline chat endpoint, so these tests check the request + * that gets built and that the abort signal reaches fetch, leaving chunk + * decoding itself to sse.test.ts. + */ + +/** A response whose body arrives in the given pieces rather than all at once. */ +function chunkedResponse(chunks: string[]): Response { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(body) +} + +const params: StreamChatParams = { + model: 'claude-sonnet-4-6', + provider: 'anthropic', + chapterContent: 'Mitochondria are complex organelles.', + selectedText: 'Mitochondria', + userMessage: 'Explain this more simply.', + history: [{ role: 'user', content: 'Hi' }], +} + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('streamChat', () => { + it('posts the chat body to /api/chat', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['ok'])) + + await streamChat(params, () => {}) + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/chat') + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(params) + }) + + it('reports decoded chunks in order', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['Mito', 'chondria are ', 'the powerhouse.'])) + const chunks: string[] = [] + + await streamChat(params, c => chunks.push(c)) + + expect(chunks).toEqual(['Mito', 'chondria are ', 'the powerhouse.']) + }) + + it('forwards the abort signal to fetch', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['ok'])) + const controller = new AbortController() + + await streamChat({ ...params, signal: controller.signal }, () => {}) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.signal).toBe(controller.signal) + }) +}) diff --git a/client/api/chat.ts b/client/api/chat.ts new file mode 100644 index 0000000..a07117e --- /dev/null +++ b/client/api/chat.ts @@ -0,0 +1,28 @@ +import type { z } from 'zod' +import type { ChatBodySchema } from '@shared/contracts' +import { streamText } from './sse' + +/** + * The inline chat endpoint, which answers with a plain text stream rather + * than a document, so it is read with streamText instead of request. + * + * The request body is inferred from the Zod schema the server validates + * against, so it cannot drift from what the route accepts. The schema is + * imported as a type only and compiles away, so no validator reaches the + * browser bundle. + */ + +/** One turn of chat history, sent to the server as context for the next reply. */ +export type ChatHistoryMessage = z.infer['history'][number] + +/** Everything streamChat needs to ask the tutor about a chapter or a selection. */ +export type StreamChatParams = z.infer & { + /** Lets the caller abort the request in flight, since the chat panel cancels one whenever the user restarts or clears the conversation. */ + signal?: AbortSignal +} + +/** Streams the tutor's reply to a chat message, one decoded chunk at a time. */ +export function streamChat(params: StreamChatParams, onChunk: (chunk: string) => void): Promise { + const { signal, ...body } = params + return streamText('/api/chat', { method: 'POST', body, signal }, onChunk) +} diff --git a/client/api/covers.test.ts b/client/api/covers.test.ts new file mode 100644 index 0000000..88073e5 --- /dev/null +++ b/client/api/covers.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { suggestCoverPrompt, generateCover, uploadCover, deleteCover } from './covers' + +/** + * A book's cover image. The AI can suggest a prompt and then generate the + * art as a background task, a reader can upload one directly, and either + * kind of cover can be removed. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('suggestCoverPrompt', () => { + it('posts to the book\'s suggest-prompt route and returns the prompt', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ prompt: 'Minimal abstract cover, punch-card motif, two colors' }), + { status: 200 }, + )) + const body = { provider: 'openai', model: 'gpt-image-1' } as const + + const result = await suggestCoverPrompt('ada', body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/cover/suggest-prompt') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ prompt: 'Minimal abstract cover, punch-card motif, two colors' }) + }) +}) + +describe('generateCover', () => { + it('posts the cover fields and returns the background task id', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ taskId: 'task-1' }), { status: 200 })) + const body = { + prompt: 'Minimal abstract cover, punch-card motif, two colors', + provider: 'openai', + model: 'gpt-image-1', + } as const + + const result = await generateCover('ada', body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/cover/generate') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ taskId: 'task-1' }) + }) + + it('rejects with the reason and status the server gave', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('{"error":"Cover generation already in progress"}', { status: 409 }), + ) + const body = { prompt: 'A cover', provider: 'openai', model: 'gpt-image-1' } as const + + const failure = await generateCover('ada', body).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(409) + expect((failure as Error).message).toBe('Cover generation already in progress') + }) +}) + +describe('uploadCover', () => { + it('posts the image data and media type', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + const body = { base64: 'aGVsbG8=', mediaType: 'image/png' } as const + + const result = await uploadCover('ada', body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/cover/upload') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ ok: true }) + }) +}) + +describe('deleteCover', () => { + it('sends a DELETE to the book\'s cover route', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + const result = await deleteCover('ada') + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/cover') + expect((init as RequestInit).method).toBe('DELETE') + expect((init as RequestInit).body).toBeUndefined() + expect(result).toEqual({ ok: true }) + }) +}) diff --git a/client/api/covers.ts b/client/api/covers.ts new file mode 100644 index 0000000..bf7f3b0 --- /dev/null +++ b/client/api/covers.ts @@ -0,0 +1,47 @@ +import type { z } from 'zod' +import type { + GenerateCoverBodySchema, + SuggestCoverPromptBodySchema, + UploadCoverBodySchema, +} from '@shared/contracts' +import { request } from './http' + +/** + * This module provides the endpoints for a book's cover image. A cover can + * be suggested and generated by AI, replaced with an uploaded image, or + * removed entirely. + */ + +type SuggestCoverPromptRequest = z.infer +type GenerateCoverRequest = z.infer +type UploadCoverRequest = z.infer + +/** The cover art prompt the AI suggests from a book's title and topic. */ +export interface SuggestCoverPromptResponse { + prompt: string +} + +/** A cover generation task that has just started, tracked by the background task stream. */ +export interface CoverGenerationTask { + taskId: string +} + +/** Ask the AI to suggest a cover art prompt from a book's title and topic. */ +export function suggestCoverPrompt(bookId: string, body: SuggestCoverPromptRequest): Promise { + return request(`/api/books/${bookId}/cover/suggest-prompt`, { method: 'POST', body }) +} + +/** Start a background task that generates a book's cover art with AI and returns its task id. */ +export function generateCover(bookId: string, body: GenerateCoverRequest): Promise { + return request(`/api/books/${bookId}/cover/generate`, { method: 'POST', body }) +} + +/** Replace a book's cover with an uploaded image. */ +export function uploadCover(bookId: string, body: UploadCoverRequest): Promise<{ ok: true }> { + return request<{ ok: true }>(`/api/books/${bookId}/cover/upload`, { method: 'POST', body }) +} + +/** Remove a book's cover. */ +export function deleteCover(bookId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/api/books/${bookId}/cover`, { method: 'DELETE' }) +} diff --git a/client/api/creation.test.ts b/client/api/creation.test.ts new file mode 100644 index 0000000..8f04392 --- /dev/null +++ b/client/api/creation.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { + createBookStream, + startFirstChapterStream, + reviseTocStream, + suggestTopic, + suggestDetails, + createSkeleton, +} from './creation' + +/** + * The creation wizard's endpoints. A topic and its details can be suggested + * by the AI, a table of contents is generated and can be revised, and once + * approved the first chapter is generated. An agentic caller can also ask + * for a bare book record instead of any of that. + */ + +/** A response whose body arrives in the given pieces rather than all at once. */ +function chunkedResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(body, init) +} + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('createBookStream', () => { + it('posts the book fields and reports every event', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + 'data: {"type":"book_created","bookId":"ada","title":"Ada Lovelace","totalChapters":10}\n', + 'data: {"type":"toc","text":"# Ada Lovelace"}\n', + 'data: {"type":"toc_done","bookId":"ada","title":"Ada Lovelace","totalChapters":10}\n', + ])) + const events: unknown[] = [] + const body = { + topic: 'Ada Lovelace', + details: 'Focus on her collaboration with Babbage', + model: 'claude-sonnet-4', + provider: 'anthropic', + quizModel: 'claude-haiku-4', + quizProvider: 'anthropic', + quizLength: 3, + chapterCount: 10, + } as const + + await createBookStream(body, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(events).toEqual([ + { type: 'book_created', bookId: 'ada', title: 'Ada Lovelace', totalChapters: 10 }, + { type: 'toc', text: '# Ada Lovelace' }, + { type: 'toc_done', bookId: 'ada', title: 'Ada Lovelace', totalChapters: 10 }, + ]) + }) +}) + +describe('startFirstChapterStream', () => { + it('posts to the book\'s start route and reports every event', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + 'data: {"type":"skills_classified"}\n', + 'data: {"type":"chapter","text":"Ada was born in 1815."}\n', + 'data: {"type":"done","bookId":"ada"}\n', + ])) + const events: unknown[] = [] + const body = { + model: 'claude-sonnet-4', + provider: 'anthropic', + quizModel: 'claude-haiku-4', + quizProvider: 'anthropic', + quizLength: 3, + } as const + + await startFirstChapterStream('ada', body, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/start') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(events).toEqual([ + { type: 'skills_classified' }, + { type: 'chapter', text: 'Ada was born in 1815.' }, + { type: 'done', bookId: 'ada' }, + ]) + }) +}) + +describe('reviseTocStream', () => { + it('posts the feedback and reports every event', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + 'data: {"type":"toc","text":"# Ada Lovelace, revised"}\n', + 'data: {"type":"toc_revised","bookId":"ada","title":"Ada Lovelace","totalChapters":8}\n', + ])) + const events: unknown[] = [] + const body = { + feedback: 'Fewer chapters on Victorian society, more on the Analytical Engine', + model: 'claude-sonnet-4', + provider: 'anthropic', + } + + await reviseTocStream('ada', body, e => events.push(e)) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/toc/revise') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(events).toEqual([ + { type: 'toc', text: '# Ada Lovelace, revised' }, + { type: 'toc_revised', bookId: 'ada', title: 'Ada Lovelace', totalChapters: 8 }, + ]) + }) +}) + +describe('suggestTopic', () => { + it('posts the suggestion fields and returns the topic and reasoning', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ topic: 'Kubernetes Networking', reasoning: 'Builds on the Docker book you finished' }), + { status: 200 }, + )) + const body = { + model: 'claude-sonnet-4', + provider: 'anthropic', + mode: 'deepen', + } as const + + const result = await suggestTopic(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/suggest') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ topic: 'Kubernetes Networking', reasoning: 'Builds on the Docker book you finished' }) + }) + + it('rejects with the reason and status the server gave', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('{"error":"No API key configured for anthropic"}', { status: 400 }), + ) + + const failure = await suggestTopic({ model: 'claude-sonnet-4' }).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(400) + expect((failure as Error).message).toBe('No API key configured for anthropic') + }) +}) + +describe('suggestDetails', () => { + it('posts the topic and returns the elaborated details', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ details: 'Cover the punch-card era through modern container networking.' }), + { status: 200 }, + )) + const body = { topic: 'Kubernetes Networking', model: 'claude-sonnet-4', provider: 'anthropic' } as const + + const result = await suggestDetails(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/suggest-details') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ details: 'Cover the punch-card era through modern container networking.' }) + }) +}) + +describe('createSkeleton', () => { + it('posts the skeleton fields and returns the created book identity', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ bookId: 'ada-12345', title: 'Ada Lovelace' }), + { status: 200 }, + )) + const body = { title: 'Ada Lovelace', prompt: 'A biography for a curious adult reader', totalChapters: 12 } + + const result = await createSkeleton(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/create-skeleton') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ bookId: 'ada-12345', title: 'Ada Lovelace' }) + }) +}) diff --git a/client/api/creation.ts b/client/api/creation.ts new file mode 100644 index 0000000..5c63bfb --- /dev/null +++ b/client/api/creation.ts @@ -0,0 +1,88 @@ +import type { z } from 'zod' +import type { + CreateBookBodySchema, + ReviseTocBodySchema, + StartBookBodySchema, + SuggestBookBodySchema, + SuggestDetailsBodySchema, +} from '@shared/contracts' +import type { CreateBookEvent, ReviseTocEvent, StartBookEvent } from '@shared/events' +import { request } from './http' +import { streamGeneration } from './sse' + +/** + * This module provides the client side of the creation wizard. It covers + * topic and detail suggestions, table of contents generation and revision, + * and the first chapter that follows approval. + */ + +type CreateBookRequest = z.infer +type StartFirstChapterRequest = z.infer +type ReviseTocRequest = z.infer +type SuggestTopicRequest = z.infer +type SuggestDetailsRequest = z.infer + +/** The topic and reasoning the AI suggests before a book is created. */ +export interface SuggestTopicResponse { + topic: string + reasoning?: string +} + +/** The elaborated focus and context the AI suggests for a chosen topic. */ +export interface SuggestDetailsResponse { + details: string +} + +/** The fields needed to create a bare book record ahead of agentic generation. */ +export interface CreateSkeletonRequest { + title: string + prompt: string + totalChapters: number +} + +/** The identity of the bare book record an agentic caller will generate into. */ +export interface CreateSkeletonResponse { + bookId: string + title: string +} + +/** Start table of contents generation for a new book and stream each event to the callback as it arrives. */ +export function createBookStream( + body: CreateBookRequest, + onEvent: (event: CreateBookEvent) => void, +): Promise { + return streamGeneration('/api/books', { method: 'POST', body }, onEvent) +} + +/** Generate the first chapter of an approved book and stream each event to the callback as it arrives. */ +export function startFirstChapterStream( + bookId: string, + body: StartFirstChapterRequest, + onEvent: (event: StartBookEvent) => void, +): Promise { + return streamGeneration(`/api/books/${bookId}/start`, { method: 'POST', body }, onEvent) +} + +/** Revise a book's table of contents from reader feedback and stream each event to the callback as it arrives. */ +export function reviseTocStream( + bookId: string, + body: ReviseTocRequest, + onEvent: (event: ReviseTocEvent) => void, +): Promise { + return streamGeneration(`/api/books/${bookId}/toc/revise`, { method: 'POST', body }, onEvent) +} + +/** Ask the AI to suggest a book topic from the reader's profile and quiz history. */ +export function suggestTopic(body: SuggestTopicRequest): Promise { + return request('/api/books/suggest', { method: 'POST', body }) +} + +/** Ask the AI to elaborate a chosen topic into a few sentences of focus and context. */ +export function suggestDetails(body: SuggestDetailsRequest): Promise { + return request('/api/books/suggest-details', { method: 'POST', body }) +} + +/** Create a bare book record with no chapters yet, for a caller that will generate the content itself. */ +export function createSkeleton(body: CreateSkeletonRequest): Promise { + return request('/api/books/create-skeleton', { method: 'POST', body }) +} diff --git a/client/lib/api-base.test.ts b/client/api/http.test.ts similarity index 56% rename from client/lib/api-base.test.ts rename to client/api/http.test.ts index 95224dc..84e9caf 100644 --- a/client/lib/api-base.test.ts +++ b/client/api/http.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -// api-base holds module-scoped readiness + base-url state. Each test imports -// a fresh copy via vi.resetModules() so cases can't leak _base/_ready between -// each other. -type ApiBase = typeof import('./api-base') +// The http transport holds module-scoped readiness and base-url state. Each +// test imports a fresh copy via vi.resetModules() so cases can't leak +// _base/_ready between each other. +type Http = typeof import('./http') -async function loadFresh(): Promise { +async function loadFresh(): Promise { vi.resetModules() - return await import('./api-base') + return await import('./http') } interface FakeElectronAPI { @@ -114,7 +114,7 @@ describe('initApiBase', () => { }) }) -describe('tracedFetch', () => { +describe('apiFetch', () => { let fetchSpy: ReturnType beforeEach(() => { @@ -134,7 +134,7 @@ describe('tracedFetch', () => { const api = await loadFresh() await api.initApiBase() - const res = await api.tracedFetch('/api/test', { method: 'POST' }) + const res = await api.apiFetch('/api/test', { method: 'POST' }) expect(res.status).toBe(200) const actualCall = fetchSpy.mock.calls[1] @@ -153,7 +153,7 @@ describe('tracedFetch', () => { .mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) // retry const api = await loadFresh() - const res = await api.tracedFetch('/api/test', { method: 'POST' }) + const res = await api.apiFetch('/api/test', { method: 'POST' }) expect(res.status).toBe(200) // logDiagnostic called twice: initial failure, then recovered @@ -184,7 +184,7 @@ describe('tracedFetch', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) - await expect(api.tracedFetch('/api/test', { method: 'POST' })).rejects.toThrow('Failed to fetch') + await expect(api.apiFetch('/api/test', { method: 'POST' })).rejects.toThrow('Failed to fetch') expect(electron.logDiagnostic).toHaveBeenCalledTimes(2) const fatal = electron.logDiagnostic.mock.calls[1][0] @@ -207,8 +207,138 @@ describe('tracedFetch', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const api = await loadFresh() - await expect(api.tracedFetch('/api/test', { method: 'POST' })).rejects.toThrow() + await expect(api.apiFetch('/api/test', { method: 'POST' })).rejects.toThrow() // We can't assert on logDiagnostic because window.electronAPI is absent in this // case — but the probe runs and the URL-resolved field tells the story. }) + + it('omits the trace header when tracing is switched off', async () => { + // A GET carrying a custom header stops being a CORS-simple request, which + // costs a preflight round trip. The hot polls opt out for that reason, so + // the header has to be genuinely absent rather than merely empty. + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })) + + await api.apiFetch('/api/books', { trace: false }) + + const headers = new Headers((fetchSpy.mock.calls[0][1] as RequestInit).headers) + expect(headers.has('X-Trace-Id')).toBe(false) + }) + + it('serialises a non-string body as JSON and declares the content type', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 })) + + await api.apiFetch('/api/books', { method: 'POST', body: { title: 'Ada' } }) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.body).toBe('{"title":"Ada"}') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('sends no body at all when none was supplied', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 })) + + await api.apiFetch('/api/books', { method: 'DELETE' }) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('forwards the abort signal', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 200 })) + const controller = new AbortController() + + await api.apiFetch('/api/books', { signal: controller.signal }) + + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBe(controller.signal) + }) +}) + +describe('request', () => { + let fetchSpy: ReturnType + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') + }) + afterEach(() => { + fetchSpy.mockRestore() + uninstallWindow() + vi.restoreAllMocks() + }) + + it('parses the JSON body of a successful response', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('{"id":"ada","title":"Ada"}', { status: 200 })) + + await expect(api.request('/api/books/ada')).resolves.toEqual({ id: 'ada', title: 'Ada' }) + }) + + it('resolves undefined for a no-content response', async () => { + // Several mutating routes answer 204. Calling json() on those throws, so + // the helper has to treat an empty body as an absent value. + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect(api.request('/api/books/ada')).resolves.toBeUndefined() + }) + + it('throws an ApiError carrying the status and the reason the server gave', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce( + new Response('{"error":"No API key configured for anthropic"}', { status: 400 }), + ) + + const failure = await api.request('/api/books/ada/generate-next', { method: 'POST' }).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(api.ApiError) + expect(failure).toBeInstanceOf(Error) + expect((failure as InstanceType).status).toBe(400) + expect((failure as Error).message).toBe('No API key configured for anthropic') + }) + + it('prefers the framework message field over the generic error field', async () => { + // Routes answer { error }. Fastify's own error serialiser answers + // { statusCode, error, message } where error is only the status name, so + // message is the more specific of the two whenever both are present. + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response( + '{"statusCode":500,"error":"Internal Server Error","message":"read ENOENT"}', + { status: 500 }, + )) + + await expect(api.request('/api/books')).rejects.toThrow('read ENOENT') + }) + + it('falls back to the supplied message when the body carries no reason', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response('gateway', { status: 502 })) + + await expect(api.request('/api/books', { fallbackMessage: 'Could not load books' })) + .rejects.toThrow('Could not load books') + }) + + it('falls back to the status when nothing else describes the failure', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 503 })) + + await expect(api.request('/api/books')).rejects.toThrow('503') + }) + + it('keeps the parsed body on the error for callers that need the detail', async () => { + const api = await loadFresh() + fetchSpy.mockResolvedValueOnce( + new Response('{"error":"Invalid request","details":[{"path":["title"]}]}', { status: 400 }), + ) + + const failure = await api.request('/api/books', { method: 'POST' }).catch((e: unknown) => e) + + expect((failure as InstanceType).body).toEqual({ + error: 'Invalid request', + details: [{ path: ['title'] }], + }) + }) }) diff --git a/client/api/http.ts b/client/api/http.ts new file mode 100644 index 0000000..a891cf3 --- /dev/null +++ b/client/api/http.ts @@ -0,0 +1,261 @@ +import { + DEFAULT_API_PORT, + HEALTH_PREWARM_ATTEMPTS, + HEALTH_PREWARM_INTERVAL_MS, + PROBE_TIMEOUT_MS, + REQUEST_RETRY_DELAY_MS, +} from '@client/lib/constants' + +let _base = '' +let _ready: Promise | null = null + +function getElectronAPI(): NonNullable | null { + return typeof window !== 'undefined' && window.electronAPI ? window.electronAPI : null +} + +export function initApiBase(): Promise { + if (_ready) return _ready + const promise = (async () => { + const electron = getElectronAPI() + if (!electron) return + const port = await electron.getApiPort() + if (!port) throw new Error('electronAPI returned no port') + _base = `http://127.0.0.1:${port}` + // Pre-warm the loopback path. Plugin registration and listen() complete + // asynchronously, so the port can be reachable while the first request + // briefly fails. Poll /api/health until it answers — usually one round + // trip, capped at ~1.5s in degenerate cases. Non-fatal if it never + // returns ok; individual requests still get their chance to fail with + // an actionable diagnostic. + for (let i = 0; i < HEALTH_PREWARM_ATTEMPTS; i++) { + try { + const r = await fetch(`${_base}/api/health`) + if (r.ok) return + } catch { /* retry */ } + await new Promise(r => setTimeout(r, HEALTH_PREWARM_INTERVAL_MS)) + } + console.warn('[init-api-base] /api/health never returned ok; proceeding anyway') + })() + _ready = promise + promise.catch(() => { _ready = null }) + return promise +} + +export function apiUrl(path: string): string { + return `${_base}${path}` +} + +export function getBase(): string { + return _base +} + +export function getApiPort(): number { + if (!_base) return DEFAULT_API_PORT + try { + const url = new URL(_base) + return url.port ? parseInt(url.port) : DEFAULT_API_PORT + } catch { + return DEFAULT_API_PORT + } +} + +type DiagnosticEntry = { + traceId: string + urlInput: string + urlResolved: string + base: string + error?: string + retryError?: string + status?: number + recovered?: boolean + stage?: 'initial' | 'recovered' | 'fatal' + durationMs?: number + probe?: Record +} + +async function diagnose(url: string): Promise> { + const probe: Record = {} + probe.urlIsAbsolute = /^https?:/.test(url) + const baseForHealth = probe.urlIsAbsolute ? new URL(url).origin : _base + try { + if (!baseForHealth) { + probe.healthSkipped = 'no base resolved' + } else { + const r = await fetch(`${baseForHealth}/api/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }) + probe.healthStatus = r.status + } + } catch (e) { + probe.healthError = String(e) + } + try { + const r = await fetch(url, { + method: 'OPTIONS', + headers: { + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type,x-trace-id', + }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }) + probe.optionsStatus = r.status + probe.allowOrigin = r.headers.get('access-control-allow-origin') + probe.allowHeaders = r.headers.get('access-control-allow-headers') + } catch (e) { + probe.optionsError = String(e) + } + return probe +} + +function log(entry: DiagnosticEntry): void { + const tag = entry.stage === 'fatal' ? '[fetch-fatal]' + : entry.stage === 'recovered' ? '[fetch-recovered]' + : '[fetch-failure]' + // Console for live debugging, file for postmortem. logDiagnostic is a + // best-effort sink — missing in non-Electron contexts. + console[entry.stage === 'fatal' ? 'error' : 'warn'](tag, entry) + void getElectronAPI()?.logDiagnostic?.(entry) +} + +/** + * What a call site may vary about a request. Deliberately narrower than + * RequestInit, because everything this app sends is either a JSON document or + * nothing at all. + */ +export interface ApiRequestInit { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + /** Serialised as JSON unless it is already a string. */ + body?: unknown + headers?: Record + signal?: AbortSignal + /** + * Stamp the request with a trace id. On by default. Switch it off for hot + * polls, where the custom header would turn a CORS-simple GET into a + * preflighted one and double the request count. + */ + trace?: boolean +} + +export interface JsonRequestInit extends ApiRequestInit { + /** Message to raise when the server fails without saying why. */ + fallbackMessage?: string +} + +/** + * A non-2xx answer from the API, carrying the status and the parsed body so a + * caller can inspect the detail rather than re-parse a string. + */ +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly body: unknown = null, + ) { + super(message) + this.name = 'ApiError' + } +} + +/** Every route answers { error }. Fastify's own serialiser adds a more specific message. */ +function reasonFrom(body: unknown): string | null { + if (!body || typeof body !== 'object') return null + const record = body as Record + for (const key of ['message', 'error']) { + const value = record[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function buildRequestInit(init: ApiRequestInit | undefined, traceId: string | null): RequestInit { + const headers = new Headers(init?.headers) + if (traceId) headers.set('X-Trace-Id', traceId) + + const request: RequestInit = { headers } + if (init?.method) request.method = init.method + if (init?.signal) request.signal = init.signal + if (init?.body !== undefined) { + if (typeof init.body === 'string') { + request.body = init.body + } else { + request.body = JSON.stringify(init.body) + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + } + } + return request +} + +/** + * The single fetch primitive for the whole client. Adds a trace id, a one-shot + * transparent retry, and a self-bisecting probe that runs at the moment of + * failure. The probe is the bisection tree from our debugging flowchart, + * answering in order whether the URL resolved absolute, whether the server is + * reachable at all, and whether CORS preflight succeeds. The answers land in a + * JSONL file that triages the next reproduction in one look. + * + * The retry fires only when fetch itself threw, meaning no response arrived. + * Every request here goes to loopback, where that outcome is a refused + * connection rather than a dropped mid-flight request, so replaying it cannot + * duplicate a side effect the server already performed. + * + * A non-2xx response is a normal return value. Use request() to turn one into + * an ApiError. + */ +export async function apiFetch(path: string, init?: ApiRequestInit): Promise { + await initApiBase() + // The id is minted even when it is not sent, so a local diagnostic entry can + // still be correlated with the console line that reported it. + const traceId = crypto.randomUUID().slice(0, 8) + const url = apiUrl(path) + const request = buildRequestInit(init, init?.trace === false ? null : traceId) + const t0 = performance.now() + + try { + return await fetch(url, request) + } catch (err) { + const probe = await diagnose(url) + const entry: DiagnosticEntry = { + traceId, + urlInput: path, + urlResolved: url, + base: _base, + error: String(err), + durationMs: Math.round(performance.now() - t0), + probe, + stage: 'initial', + } + log(entry) + + await new Promise(r => setTimeout(r, REQUEST_RETRY_DELAY_MS)) + try { + const res = await fetch(url, request) + log({ ...entry, stage: 'recovered', recovered: true, status: res.status }) + return res + } catch (retryErr) { + log({ ...entry, stage: 'fatal', retryError: String(retryErr) }) + throw retryErr + } + } +} + +/** + * Turn a non-2xx response into an ApiError, reading whatever reason the body + * offers. Returns the response untouched when it is fine, so it can sit inline + * in a call chain. Streaming callers use this directly, since they consume the + * body themselves rather than parsing it as JSON. + */ +export async function expectOk(response: Response, fallbackMessage?: string): Promise { + if (response.ok) return response + const body = await response.json().catch(() => null) + const reason = reasonFrom(body) ?? fallbackMessage ?? `Request failed with status ${response.status}` + throw new ApiError(response.status, reason, body) +} + +/** + * Send a request and return its parsed JSON body, raising an ApiError for any + * non-2xx answer. This is the shape almost every endpoint module wants. + */ +export async function request(path: string, init?: JsonRequestInit): Promise { + const response = await expectOk(await apiFetch(path, init), init?.fallbackMessage) + // Several mutating routes answer 204, and json() rejects on an empty body. + const text = await response.text() + return (text ? JSON.parse(text) : undefined) as T +} diff --git a/client/api/import.test.ts b/client/api/import.test.ts new file mode 100644 index 0000000..56803f7 --- /dev/null +++ b/client/api/import.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { previewEpubImport, confirmEpubImport } from './import' + +/** + * Bringing an existing EPUB into the library. The file is parsed into a + * preview the reader can adjust, then confirmed into a finished book. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('previewEpubImport', () => { + it('posts the file and returns the parsed preview', async () => { + const preview = { title: 'Ada Lovelace', chapterCount: 12, hasCover: true, coverBase64: 'aGVsbG8=' } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(preview), { status: 200 })) + const body = { base64: 'ZmFrZS1lcHVi', filename: 'ada-lovelace.epub' } + + const result = await previewEpubImport(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/import/preview') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual(preview) + }) + + it('rejects with the reason and status the server gave', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"Not a valid EPUB file"}', { status: 400 })) + + const failure = await previewEpubImport({ base64: 'bm90YW56aXA=', filename: 'not-a-book.epub' }) + .catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(400) + expect((failure as Error).message).toBe('Not a valid EPUB file') + }) +}) + +describe('confirmEpubImport', () => { + it('posts every field when tags, series and seriesOrder are all supplied', async () => { + const book = { + id: 'ada-lovelace', + title: 'Ada Lovelace', + prompt: 'Imported from EPUB', + status: 'complete', + totalChapters: 12, + generatedUpTo: 12, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + tags: ['biography'], + audioGeneratedChapters: [], + } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ book }), { status: 200 })) + const body = { + base64: 'ZmFrZS1lcHVi', + filename: 'ada-lovelace.epub', + tags: ['biography'], + series: 'Great Mathematicians', + seriesOrder: 2, + } + + const result = await confirmEpubImport(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/import/confirm') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual({ book }) + }) + + it('omits tags, series and seriesOrder from the body when they are not supplied', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ book: {} }), { status: 200 })) + const body = { base64: 'ZmFrZS1lcHVi', filename: 'ada-lovelace.epub' } + + await confirmEpubImport(body) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.body).toBe(JSON.stringify({ base64: 'ZmFrZS1lcHVi', filename: 'ada-lovelace.epub' })) + expect(init.body).not.toContain('tags') + expect(init.body).not.toContain('series') + expect(init.body).not.toContain('seriesOrder') + }) +}) diff --git a/client/api/import.ts b/client/api/import.ts new file mode 100644 index 0000000..8d0c2b2 --- /dev/null +++ b/client/api/import.ts @@ -0,0 +1,24 @@ +import type { z } from 'zod' +import type { ImportEpubBodySchema, ImportEpubConfirmBodySchema } from '@shared/contracts' +import type { BookMeta } from '@shared/domain' +import type { EpubPreview } from '@shared/responses' +import { request } from './http' + +/** + * This module provides the endpoints for bringing an existing EPUB into the + * library. A file is parsed into a preview the reader can adjust, then + * confirmed into a finished book. + */ + +type PreviewEpubImportRequest = z.infer +type ConfirmEpubImportRequest = z.infer + +/** Parse an EPUB file and report its title, chapter count, and cover ahead of import. */ +export function previewEpubImport(body: PreviewEpubImportRequest): Promise { + return request('/api/books/import/preview', { method: 'POST', body }) +} + +/** Import a previewed EPUB into the library as a finished book. */ +export function confirmEpubImport(body: ConfirmEpubImportRequest): Promise<{ book: BookMeta }> { + return request<{ book: BookMeta }>('/api/books/import/confirm', { method: 'POST', body }) +} diff --git a/client/api/index.ts b/client/api/index.ts new file mode 100644 index 0000000..ff77579 --- /dev/null +++ b/client/api/index.ts @@ -0,0 +1,31 @@ +/** + * The client's only door to the server. + * + * Every component and hook imports from here rather than from a module inside + * this folder, so there is exactly one import path to grep for and exactly one + * place a new endpoint can be added. Nothing outside this folder calls fetch + * or constructs an EventSource, and a lint rule enforces that. + * + * The folder is arranged by resource. `http.ts` holds the transport, meaning + * the base URL, the trace id, the retry and the error type. `sse.ts` holds the + * three streaming shapes. `urls.ts` builds the URLs handed to an img or audio + * element rather than to fetch. Everything else is one file per group of + * endpoints. + */ + +export { ApiError, apiUrl, getApiPort, initApiBase } from './http' +export type { ApiRequestInit, JsonRequestInit } from './http' + +export { audiobookFileUrl, coverUrl, voicePreviewUrl } from './urls' + +export * from './audiobook' +export * from './books' +export * from './chapters' +export * from './chat' +export * from './covers' +export * from './creation' +export * from './import' +export * from './profile' +export * from './progress' +export * from './settings' +export * from './tasks' diff --git a/client/api/profile.test.ts b/client/api/profile.test.ts new file mode 100644 index 0000000..ead61b6 --- /dev/null +++ b/client/api/profile.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Preferences } from '@shared/domain' +import { ApiError } from './http' +import { + getProfile, + saveProfile, + suggestSkills, + getProfileSuggestions, + streamInterview, + type ProfileResponse, + type InterviewValue, +} from './profile' + +/** + * The learning profile, its skills, and the AI interview and suggestion + * flows that shape it. getProfile and saveProfile both use the wire shape + * this module names ProfileResponse rather than the LearningProfile domain + * type, since the server folds identity and style into a single aboutMe + * string before it answers. + */ + +const SAMPLE_PREFERENCES: Preferences = { + explainComplexTermsSimply: true, + codeExamples: true, + realWorldAnalogies: true, + includeRecaps: true, + includeSummaries: true, + visualDescriptions: false, + depthLevel: 3, + pacePreference: 3, + metaphorDensity: 3, + narrativeStyle: 3, + humorLevel: 2, + formalityLevel: 3, +} + +/** A response whose body arrives in the given pieces rather than all at once, matching the helper sse.test.ts uses for the same purpose. */ +function chunkedResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(body, init) +} + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() +}) + +describe('getProfile', () => { + it('requests the profile', async () => { + const payload: ProfileResponse = { + aboutMe: 'A backend engineer learning audio synthesis.', + preferences: SAMPLE_PREFERENCES, + skills: [{ name: 'TypeScript', level: 8 }], + } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const result = await getProfile() + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/profile') + expect((init as RequestInit).method).toBeUndefined() + expect(result).toEqual(payload) + }) + + it('throws an ApiError carrying the status and the reason the server gave', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'Profile is corrupt' }), { status: 500 })) + + const failure = await getProfile().catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(500) + expect((failure as Error).message).toBe('Profile is corrupt') + }) +}) + +describe('saveProfile', () => { + it('puts the profile with the given about me, preferences, and skills', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + const profile: ProfileResponse = { + aboutMe: 'A backend engineer learning audio synthesis.', + preferences: SAMPLE_PREFERENCES, + skills: [{ name: 'TypeScript', level: 8 }], + } + + await saveProfile(profile) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/profile') + expect((init as RequestInit).method).toBe('PUT') + expect((init as RequestInit).body).toBe(JSON.stringify(profile)) + }) +}) + +describe('suggestSkills', () => { + it('posts the about me text and existing skills, and unwraps the response', async () => { + const suggested = [{ name: 'Rust', level: 4 }] + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ skills: suggested }), { status: 200 })) + const body = { + model: 'claude-sonnet-4-5', + provider: 'anthropic' as const, + aboutMe: 'A backend engineer.', + existingSkills: [{ name: 'TypeScript', level: 8 }], + } + + const result = await suggestSkills(body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/profile/suggest-skills') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual(suggested) + }) +}) + +describe('getProfileSuggestions', () => { + it('posts the model and provider for one book, and returns the suggestions', async () => { + const suggestions = { + rationale: 'Quiz scores were strong on async patterns.', + skills: { added: [{ name: 'Concurrency', level: 6 }], removed: [], updated: [] }, + preferences: [{ key: 'depthLevel', oldValue: 3, newValue: 4 }], + aboutMe: 'A backend engineer who now understands audio synthesis.', + } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(suggestions), { status: 200 })) + const body = { model: 'claude-sonnet-4-5', provider: 'anthropic' as const } + + const result = await getProfileSuggestions('ada', body) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/books/ada/profile-suggestions') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(result).toEqual(suggestions) + }) + + it('throws an ApiError carrying the status and the reason the server gave', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Book is not fully generated' }), { status: 400 }), + ) + + const failure = await getProfileSuggestions('ada', { model: 'claude-sonnet-4-5' }).catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(400) + expect((failure as Error).message).toBe('Book is not fully generated') + }) +}) + +describe('streamInterview', () => { + it('posts the message and history, and reports assistant text', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['{"type":"text","content":"Tell me more."}\n'])) + const body = { + model: 'claude-sonnet-4-5', + provider: 'anthropic' as const, + userMessage: 'I am a backend engineer.', + history: [], + } + const values: InterviewValue[] = [] + + await streamInterview(body, v => values.push(v)) + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/profile/interview') + expect((init as RequestInit).method).toBe('POST') + expect((init as RequestInit).body).toBe(JSON.stringify(body)) + expect(values).toEqual([{ type: 'text', content: 'Tell me more.' }]) + }) + + it('reports both assistant text and the finished profile, the last of which arrives with no trailing newline', async () => { + const profile: ProfileResponse = { + aboutMe: 'A backend engineer learning audio synthesis.', + preferences: SAMPLE_PREFERENCES, + skills: [{ name: 'TypeScript', level: 8 }], + } + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + '{"type":"text","content":"Got it."}\n', + `{"type":"profile_complete","profile":${JSON.stringify(profile)}}`, + ])) + const values: InterviewValue[] = [] + + await streamInterview( + { model: 'claude-sonnet-4-5', userMessage: 'That is everything.', history: [] }, + v => values.push(v), + ) + + expect(values).toEqual([ + { type: 'text', content: 'Got it.' }, + { type: 'profile_complete', profile }, + ]) + }) + + it('forwards the abort signal', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['{"type":"text","content":"hi"}\n'])) + const controller = new AbortController() + + await streamInterview( + { model: 'claude-sonnet-4-5', userMessage: 'hi', history: [] }, + () => {}, + controller.signal, + ) + + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBe(controller.signal) + }) +}) diff --git a/client/api/profile.ts b/client/api/profile.ts new file mode 100644 index 0000000..28f331d --- /dev/null +++ b/client/api/profile.ts @@ -0,0 +1,95 @@ +import type { z } from 'zod' +import type { + AiRequestSchema, + InterviewChatBodySchema, + SuggestSkillsBodySchema, + UpdateProfileBodySchema, +} from '@shared/contracts' +import type { LearningProfile, Preferences } from '@shared/domain' +import { request } from './http' +import { streamNdjson } from './sse' + +/** + * Endpoints for the learning profile, its skills, and the AI interview and + * suggestion flows that shape it. + * + * Request bodies are inferred from the Zod schemas the server validates + * against, so a body this module sends cannot drift from what the route + * accepts. The schemas are imported as types only and compile away, so no + * validator reaches the browser bundle. + */ + +/** One skill in the learning profile's prior knowledge list, reusing the shape LearningProfile already declares. */ +export type Skill = LearningProfile['skills'][number] + +/** The model and provider choice every AI-backed profile call sends. */ +type AiRequest = z.infer + +/** + * The learning profile as the server answers or accepts it over the wire. + * + * This is not LearningProfile from shared/domain.ts. That type's fields are + * style and identity, the shape the profile is persisted as on disk. The + * profile route folds those two fields into a single aboutMe string before + * it answers. That aboutMe field is what every caller actually reads, so + * this module names the wire shape on its own rather than importing a type + * that would be misleading. + */ +export interface ProfileResponse { + aboutMe: string + preferences: Preferences + skills: Skill[] +} + +/** Fetch the learning profile, meaning About Me, preferences, and prior knowledge skills. */ +export async function getProfile(): Promise { + return request('/api/profile') +} + +/** Persist the learning profile, meaning About Me, preferences, and prior knowledge skills. */ +export async function saveProfile(profile: z.infer): Promise { + await request('/api/profile', { method: 'PUT', body: profile }) +} + +/** What suggestSkills sends, meaning the reader's background and the skills already on file. */ +export type SuggestSkillsBody = z.infer + +/** Ask the model to suggest skills to add, given the reader's About Me text and existing skills. */ +export async function suggestSkills(body: SuggestSkillsBody): Promise { + const { skills } = await request<{ skills: Skill[] }>('/api/profile/suggest-skills', { method: 'POST', body }) + return skills +} + +/** Suggested updates to one reader's profile after finishing a book, covering skills, preferences, and a rewritten About Me. */ +export interface ProfileSuggestions { + rationale: string + skills: { + added: Array<{ name: string; level: number }> + removed: string[] + updated: Array<{ name: string; oldLevel: number; newLevel: number }> + } + preferences: Array<{ key: string; oldValue: boolean | number; newValue: boolean | number }> + aboutMe: string +} + +/** Ask the model to suggest profile updates based on one finished book's feedback and quiz history. */ +export async function getProfileSuggestions(bookId: string, body: AiRequest): Promise { + return request(`/api/books/${bookId}/profile-suggestions`, { method: 'POST', body }) +} + +/** What streamInterview sends on each turn, meaning the reader's latest message and the conversation so far. */ +export type InterviewChatBody = z.infer + +/** One value emitted by the profile interview stream, either assistant text or the finished profile. */ +export type InterviewValue = + | { type: 'text'; content: string } + | { type: 'profile_complete'; profile: ProfileResponse } + +/** Stream one turn of the learning profile interview, forwarding assistant text and the finished profile as they arrive. */ +export async function streamInterview( + body: InterviewChatBody, + onValue: (value: InterviewValue) => void, + signal?: AbortSignal, +): Promise { + await streamNdjson('/api/profile/interview', { method: 'POST', body, signal }, onValue) +} diff --git a/client/api/progress.test.ts b/client/api/progress.test.ts new file mode 100644 index 0000000..045a744 --- /dev/null +++ b/client/api/progress.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { getSkillProgress } from './progress' + +/** The progress endpoint rolls up skill mastery across every book into one document, so there is exactly one call to pin here. */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() +}) + +describe('getSkillProgress', () => { + it('requests the rolled up skill progress', async () => { + const payload = { + stats: { totalBooks: 2, completedBooks: 1, totalChapters: 10, completedChapters: 6 }, + skills: [ + { + name: 'TypeScript', + totalWeight: 10, + completedWeight: 6, + books: [{ bookId: 'ada', title: 'Ada', weight: 10, completed: false }], + subskills: [], + }, + ], + } + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const result = await getSkillProgress() + + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('/api/progress/skills') + expect((init as RequestInit).method).toBeUndefined() + expect(result).toEqual(payload) + }) + + it('throws an ApiError carrying the status and the reason the server gave', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'No progress data yet' }), { status: 404 }), + ) + + const failure = await getSkillProgress().catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(404) + expect((failure as Error).message).toBe('No progress data yet') + }) +}) diff --git a/client/api/progress.ts b/client/api/progress.ts new file mode 100644 index 0000000..57e951d --- /dev/null +++ b/client/api/progress.ts @@ -0,0 +1,9 @@ +import type { SkillProgress } from '@shared/responses' +import { request } from './http' + +/** Endpoint for skill mastery rolled up across every book. */ + +/** Fetch skill mastery and completion stats rolled up across every book. */ +export async function getSkillProgress(): Promise { + return request('/api/progress/skills') +} diff --git a/client/api/settings.test.ts b/client/api/settings.test.ts new file mode 100644 index 0000000..c567298 --- /dev/null +++ b/client/api/settings.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { saveApiKey, removeApiKey, getApiKeyStatus, checkHealth, getProviderModels } from './settings' + +/** + * Each of these wraps a single fetch through the shared request or apiFetch + * helper, so these tests check the method, the resolved URL and the body + * that gets built, leaving the transport itself to http.test.ts. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('saveApiKey', () => { + it('posts the provider and the key to /api/settings/api-key', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await saveApiKey('anthropic', 'sk-ant-test') + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/settings/api-key') + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ provider: 'anthropic', apiKey: 'sk-ant-test' }) + }) +}) + +describe('removeApiKey', () => { + it('sends a DELETE to /api/settings/api-key with the provider in the body', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await removeApiKey('openai') + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/settings/api-key') + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(init.method).toBe('DELETE') + // The body is what makes this DELETE unusual, so its presence gets its + // own explicit assertion rather than an assumption. + expect(JSON.parse(init.body as string)).toEqual({ provider: 'openai' }) + }) +}) + +describe('getApiKeyStatus', () => { + it('gets the configured status for every provider', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('{"anthropic":true,"openai":false,"google":false}', { status: 200 }), + ) + + const status = await getApiKeyStatus() + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/settings/api-key-status') + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('GET') + expect(status).toEqual({ anthropic: true, openai: false, google: false }) + }) +}) + +describe('checkHealth', () => { + it('resolves true on a 200 from GET /api/health', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect(checkHealth()).resolves.toBe(true) + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/health') + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('GET') + }) + + it('resolves false on a non-2xx response rather than throwing', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 503 })) + + await expect(checkHealth()).resolves.toBe(false) + }) + + it('resolves false rather than throwing when fetch itself rejects', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchSpy.mockRejectedValue(new TypeError('Failed to fetch')) + + await expect(checkHealth()).resolves.toBe(false) + }) + + it('omits the trace header, since the ten second poll would otherwise be preflighted', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await checkHealth() + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect(new Headers(init.headers).has('X-Trace-Id')).toBe(false) + }) +}) + +describe('getProviderModels', () => { + it('gets the chat and image models for a provider', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + '{"chat":[{"value":"claude-sonnet-4-6","label":"Claude Sonnet 4.6"}],"image":[]}', + { status: 200 }, + )) + + const models = await getProviderModels('anthropic') + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/providers/anthropic/models') + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('GET') + expect(models).toEqual({ chat: [{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }], image: [] }) + }) +}) diff --git a/client/api/settings.ts b/client/api/settings.ts new file mode 100644 index 0000000..c4c7608 --- /dev/null +++ b/client/api/settings.ts @@ -0,0 +1,45 @@ +import type { ModelOption, ProviderId } from '@client/lib/providers' +import { apiFetch, request } from './http' + +/** + * This module covers API keys, the server health check, and the model lists + * a provider currently offers. + */ + +/** Saves an API key for a provider so the server can use it for future requests. */ +export function saveApiKey(provider: ProviderId, apiKey: string): Promise { + return request('/api/settings/api-key', { method: 'POST', body: { provider, apiKey } }) +} + +/** Removes the stored API key for a provider. */ +export function removeApiKey(provider: ProviderId): Promise { + // This DELETE carries a body on purpose. The server reads the provider + // from the body, and a query parameter would just be a second way to send + // the same thing. + return request('/api/settings/api-key', { method: 'DELETE', body: { provider } }) +} + +/** Reports which providers currently have an API key configured on the server. */ +export function getApiKeyStatus(): Promise> { + return request>('/api/settings/api-key-status', { method: 'GET' }) +} + +/** Checks whether the server is reachable, and never throws while doing it. */ +export async function checkHealth(): Promise { + try { + // This poll runs on a ten second interval. Passing trace as false keeps + // the request CORS-simple, since the trace header would otherwise force + // a preflight on every one of them. + const response = await apiFetch('/api/health', { method: 'GET', trace: false }) + return response.ok + } catch { + // A poller has no use for a thrown error, so a transport failure counts + // the same as an unreachable server. + return false + } +} + +/** Lists the chat and image models a provider currently offers. */ +export function getProviderModels(provider: ProviderId): Promise<{ chat: ModelOption[]; image: ModelOption[] }> { + return request<{ chat: ModelOption[]; image: ModelOption[] }>(`/api/providers/${provider}/models`, { method: 'GET' }) +} diff --git a/client/api/sse.test.ts b/client/api/sse.test.ts new file mode 100644 index 0000000..c60e538 --- /dev/null +++ b/client/api/sse.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { ApiError } from './http' +import { streamGeneration, streamText, streamNdjson, subscribeToTasks } from './sse' + +/** + * The three streaming shapes this app consumes are server-sent events for + * generation, raw text for inline chat, and newline delimited JSON for the + * profile interview. Each has a hand rolled reader today, and these tests pin + * the behaviour those readers have to keep, particularly around partial + * chunks, which is where a hand rolled reader usually goes wrong. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +/** A response whose body arrives in the given pieces rather than all at once. */ +function chunkedResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(body, init) +} + +describe('streamGeneration', () => { + it('reports every event in the stream', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + 'data: {"type":"stage","stage":"outline"}\n', + 'data: {"type":"chapter","text":"Once upon"}\n', + 'data: {"type":"done","chapterNum":1}\n', + ])) + const events: unknown[] = [] + + await streamGeneration('/api/books/ada/generate-next', { method: 'POST' }, e => events.push(e)) + + expect(events).toEqual([ + { type: 'stage', stage: 'outline' }, + { type: 'chapter', text: 'Once upon' }, + { type: 'done', chapterNum: 1 }, + ]) + }) + + it('reassembles an event split across chunk boundaries', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['data: {"type":"chap', 'ter","text":"hi"}\n'])) + const events: unknown[] = [] + + await streamGeneration('/api/books/ada/generate-next', undefined, e => events.push(e)) + + expect(events).toEqual([{ type: 'chapter', text: 'hi' }]) + }) + + it('raises the reason the server gave instead of opening a stream', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('{"error":"Generation already in progress for this book"}', { status: 409 }), + ) + + const failure = await streamGeneration('/api/books/ada/generate-next', { method: 'POST' }, () => {}) + .catch((e: unknown) => e) + + expect(failure).toBeInstanceOf(ApiError) + expect((failure as ApiError).status).toBe(409) + expect((failure as Error).message).toBe('Generation already in progress for this book') + }) + + it('raises when the response carries no body to read', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect(streamGeneration('/api/books/ada/generate-next', undefined, () => {})) + .rejects.toBeInstanceOf(ApiError) + }) +}) + +describe('streamText', () => { + it('reports decoded chunks in order', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['Mitochondria ', 'are the ', 'powerhouse.'])) + const chunks: string[] = [] + + await streamText('/api/chat', { method: 'POST', body: {} }, c => chunks.push(c)) + + expect(chunks).toEqual(['Mitochondria ', 'are the ', 'powerhouse.']) + }) + + it('never splits a multi-byte character across chunks', async () => { + // The euro sign is three bytes. Arriving split, a non-streaming decoder + // would emit a replacement character in place of it. + const euro = new TextEncoder().encode('€') + const body = new ReadableStream({ + start(controller) { + controller.enqueue(euro.slice(0, 2)) + controller.enqueue(euro.slice(2)) + controller.close() + }, + }) + fetchSpy.mockResolvedValueOnce(new Response(body)) + const chunks: string[] = [] + + await streamText('/api/chat', undefined, c => chunks.push(c)) + + expect(chunks.join('')).toBe('€') + }) + + it('raises the reason the server gave', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"error":"No API key configured for openai"}', { status: 400 })) + + await expect(streamText('/api/chat', { method: 'POST' }, () => {})) + .rejects.toThrow('No API key configured for openai') + }) +}) + +describe('streamNdjson', () => { + it('reports one value per line', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + '{"type":"text","content":"Hello"}\n{"type":"text","content":" there"}\n', + ])) + const lines: unknown[] = [] + + await streamNdjson('/api/profile/interview', { method: 'POST' }, l => lines.push(l)) + + expect(lines).toEqual([ + { type: 'text', content: 'Hello' }, + { type: 'text', content: ' there' }, + ]) + }) + + it('reports a final value that arrived without a trailing newline', async () => { + // The interview endpoint ends this way, and the completed profile is the + // last value it sends, so dropping it would lose the whole result. + fetchSpy.mockResolvedValueOnce(chunkedResponse([ + '{"type":"text","content":"done"}\n{"type":"profile_complete","profile":{"aboutMe":"x"}}', + ])) + const lines: unknown[] = [] + + await streamNdjson('/api/profile/interview', undefined, l => lines.push(l)) + + expect(lines).toHaveLength(2) + expect(lines[1]).toEqual({ type: 'profile_complete', profile: { aboutMe: 'x' } }) + }) + + it('skips a line that is not JSON and keeps reading', async () => { + fetchSpy.mockResolvedValueOnce(chunkedResponse(['not json\n{"type":"text","content":"ok"}\n'])) + const lines: unknown[] = [] + + await streamNdjson('/api/profile/interview', undefined, l => lines.push(l)) + + expect(lines).toEqual([{ type: 'text', content: 'ok' }]) + }) +}) + +/** Stands in for the browser EventSource, which node does not provide. */ +class FakeEventSource { + static instances: FakeEventSource[] = [] + onmessage: ((event: { data: string }) => void) | null = null + onerror: (() => void) | null = null + closed = false + + constructor(readonly url: string) { + FakeEventSource.instances.push(this) + } + + close(): void { + this.closed = true + } + + static reset(): void { + FakeEventSource.instances = [] + } + + static get latest(): FakeEventSource { + return FakeEventSource.instances[FakeEventSource.instances.length - 1] + } +} + +describe('subscribeToTasks', () => { + beforeEach(() => { + FakeEventSource.reset() + vi.stubGlobal('EventSource', FakeEventSource) + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('opens the task stream and reports each event', () => { + const events: unknown[] = [] + subscribeToTasks(e => events.push(e)) + + expect(FakeEventSource.latest.url).toContain('/api/tasks/stream') + FakeEventSource.latest.onmessage?.({ data: '{"type":"task_progress","taskId":"t1"}' }) + + expect(events).toEqual([{ type: 'task_progress', taskId: 't1' }]) + }) + + it('ignores a frame that does not parse', () => { + const events: unknown[] = [] + subscribeToTasks(e => events.push(e)) + + expect(() => FakeEventSource.latest.onmessage?.({ data: '{"type":' })).not.toThrow() + expect(events).toEqual([]) + }) + + it('reopens the stream after it drops', () => { + subscribeToTasks(() => {}) + const first = FakeEventSource.latest + + first.onerror?.() + expect(first.closed).toBe(true) + expect(FakeEventSource.instances).toHaveLength(1) + + vi.advanceTimersByTime(3_000) + expect(FakeEventSource.instances).toHaveLength(2) + expect(FakeEventSource.latest).not.toBe(first) + }) + + it('stops reconnecting once unsubscribed', () => { + // The reader mounts and unmounts freely, so a reconnect scheduled just + // before teardown must not resurrect the stream afterwards. + const unsubscribe = subscribeToTasks(() => {}) + FakeEventSource.latest.onerror?.() + + unsubscribe() + vi.advanceTimersByTime(30_000) + + expect(FakeEventSource.instances).toHaveLength(1) + }) + + it('closes the live stream on unsubscribe', () => { + const unsubscribe = subscribeToTasks(() => {}) + unsubscribe() + + expect(FakeEventSource.latest.closed).toBe(true) + }) +}) diff --git a/client/api/sse.ts b/client/api/sse.ts new file mode 100644 index 0000000..a67362d --- /dev/null +++ b/client/api/sse.ts @@ -0,0 +1,149 @@ +import { TASK_STREAM_RECONNECT_MS } from '@client/lib/constants' +import { parseSSEStream } from '@client/lib/parse-sse-stream' +import { ApiError, apiFetch, apiUrl, expectOk, type JsonRequestInit } from './http' + +/** + * The streaming half of the API client. + * + * Generation, inline chat and the profile interview all answer with a stream + * rather than a document, in three different encodings. Everything below turns + * one of those encodings into a callback, so no component has to own a reader + * loop, a text decoder, or a reconnect timer. + */ + +/** + * Start a request and hand back a response that is guaranteed to have a body + * worth reading. A stream endpoint that fails does so before the first byte, + * answering with an ordinary JSON error, which is why the failure path here is + * the same one every other request uses. + */ +async function openStream(path: string, init: JsonRequestInit | undefined): Promise { + const response = await expectOk(await apiFetch(path, init), init?.fallbackMessage) + if (!response.body) { + throw new ApiError(response.status, init?.fallbackMessage ?? 'The server opened no stream') + } + return response +} + +/** + * Read a stream to its end, handing each decoded chunk to the caller. The + * decoder is kept in streaming mode so a multi-byte character split across two + * network chunks is reassembled rather than mangled. + */ +async function readChunks(response: Response, onChunk: (chunk: string) => void): Promise { + // Only ever called on a response from openStream, which has already + // established that the body is there. + const reader = response.body!.getReader() + const decoder = new TextDecoder() + for (;;) { + const { done, value } = await reader.read() + if (done) return + onChunk(decoder.decode(value, { stream: true })) + } +} + +/** + * Consume a server-sent event stream, reporting each parsed event. Used by + * every generation endpoint, which is anything that writes a chapter or a + * table of contents while the user watches. + * + * The event type is supplied by the caller from `@shared/events`, which names + * one union per stream rather than one loose union for all of them, so a + * handler cannot claim to receive an event its endpoint never sends. + */ +export async function streamGeneration( + path: string, + init: JsonRequestInit | undefined, + onEvent: (event: TEvent) => void, +): Promise { + await parseSSEStream(await openStream(path, init), { onEvent }) +} + +/** Consume a plain text stream, reporting each chunk. Used by inline chat. */ +export async function streamText( + path: string, + init: JsonRequestInit | undefined, + onChunk: (chunk: string) => void, +): Promise { + await readChunks(await openStream(path, init), onChunk) +} + +/** + * Consume a newline delimited JSON stream, reporting each parsed value. Used + * by the profile interview, which interleaves assistant text with the finished + * profile. + */ +export async function streamNdjson( + path: string, + init: JsonRequestInit | undefined, + onValue: (value: T) => void, +): Promise { + const response = await openStream(path, init) + let buffer = '' + + const emit = (line: string): void => { + if (!line.trim()) return + try { + onValue(JSON.parse(line) as T) + } catch { + // A line that is not JSON is a server-side artefact, not something the + // reader can act on. Keep going rather than abandoning the stream. + } + } + + await readChunks(response, chunk => { + buffer += chunk + const lines = buffer.split('\n') + // The last piece is whatever came after the final newline, which is either + // an empty string or the start of a value still in flight. + buffer = lines.pop() ?? '' + for (const line of lines) emit(line) + }) + + // The stream can end without a trailing newline, and for the interview the + // completed profile is exactly that last unterminated line. + emit(buffer) +} + +/** + * Follow the background task stream until the returned function is called. + * + * The connection is re-opened after a drop, which happens routinely when the + * server restarts during development. Reconnection is owned here rather than + * by a component so the timer cannot be torn down and rebuilt by an unrelated + * re-render. + * + * The event type is the caller's to name, because the transport neither knows + * nor needs to know the task vocabulary. + */ +export function subscribeToTasks(onEvent: (event: E) => void): () => void { + let source: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let unsubscribed = false + + const connect = (): void => { + source = new EventSource(apiUrl('/api/tasks/stream')) + + source.onmessage = event => { + try { + onEvent(JSON.parse(event.data) as E) + } catch { + // A truncated frame tells us nothing actionable. + } + } + + source.onerror = () => { + source?.close() + if (unsubscribed) return + reconnectTimer = setTimeout(connect, TASK_STREAM_RECONNECT_MS) + } + } + + connect() + + return () => { + unsubscribed = true + source?.close() + if (reconnectTimer) clearTimeout(reconnectTimer) + } +} diff --git a/client/api/tasks.test.ts b/client/api/tasks.test.ts new file mode 100644 index 0000000..07c5687 --- /dev/null +++ b/client/api/tasks.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { cancelTask, subscribeToTaskEvents } from './tasks' + +/** + * cancelTask is a thin wrapper over the shared request helper, and + * subscribeToTaskEvents pins subscribeToTasks from sse.ts to the TaskEvent + * union, so these tests check that wiring rather than re-testing the + * transport or the reconnect logic, which http.test.ts and sse.test.ts + * already cover. + */ + +let fetchSpy: ReturnType + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch') +}) +afterEach(() => { + fetchSpy.mockRestore() + vi.restoreAllMocks() +}) + +describe('cancelTask', () => { + it('sends a DELETE to /api/tasks/:taskId', async () => { + fetchSpy.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + + await cancelTask('task-123') + + expect(fetchSpy.mock.calls[0][0]).toBe('/api/tasks/task-123') + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('DELETE') + }) +}) + +/** Stands in for the browser EventSource, which node does not provide. Mirrors the fake in sse.test.ts. */ +class FakeEventSource { + static instances: FakeEventSource[] = [] + onmessage: ((event: { data: string }) => void) | null = null + onerror: (() => void) | null = null + closed = false + + constructor(readonly url: string) { + FakeEventSource.instances.push(this) + } + + close(): void { + this.closed = true + } + + static reset(): void { + FakeEventSource.instances = [] + } + + static get latest(): FakeEventSource { + return FakeEventSource.instances[FakeEventSource.instances.length - 1] + } +} + +describe('subscribeToTaskEvents', () => { + beforeEach(() => { + FakeEventSource.reset() + vi.stubGlobal('EventSource', FakeEventSource) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reports each event on the stream to the callback', () => { + const events: unknown[] = [] + subscribeToTaskEvents(e => events.push(e)) + + FakeEventSource.latest.onmessage?.({ + data: '{"type":"task_progress","taskId":"t1","progress":{"current":1,"total":3,"label":"Chapter 2"}}', + }) + + expect(events).toEqual([ + { type: 'task_progress', taskId: 't1', progress: { current: 1, total: 3, label: 'Chapter 2' } }, + ]) + }) + + it('closes the stream when the caller unsubscribes', () => { + const unsubscribe = subscribeToTaskEvents(() => {}) + + unsubscribe() + + expect(FakeEventSource.latest.closed).toBe(true) + }) +}) diff --git a/client/api/tasks.ts b/client/api/tasks.ts new file mode 100644 index 0000000..34aaffe --- /dev/null +++ b/client/api/tasks.ts @@ -0,0 +1,23 @@ +import type { TaskEvent } from '@shared/events' +import { request } from './http' +import { subscribeToTasks } from './sse' + +/** + * This module cancels background tasks and streams their events live, which + * the background tasks footer and the generate-all modal both consume. + */ + +/** Cancels a running background task. */ +export function cancelTask(taskId: string): Promise { + return request(`/api/tasks/${taskId}`, { method: 'DELETE' }) +} + +/** + * Subscribes to the background task stream and pins its event type to + * TaskEvent in this one place, so the footer and the generate-all modal + * cannot disagree about the events they read. Returns the unsubscribe + * function unchanged. + */ +export function subscribeToTaskEvents(onEvent: (event: TaskEvent) => void): () => void { + return subscribeToTasks(onEvent) +} diff --git a/client/api/urls.test.ts b/client/api/urls.test.ts new file mode 100644 index 0000000..5f93960 --- /dev/null +++ b/client/api/urls.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { audiobookFileUrl, coverUrl, voicePreviewUrl } from './urls' + +/** + * These URLs are handed to an img tag or an Audio element rather than to + * fetch, so they are the one place the client still builds a URL by hand. The + * version tag is the interesting part. Covers and audiobooks are overwritten + * in place, so without it the browser keeps serving the old bytes after a + * regeneration. + */ + +describe('coverUrl', () => { + it('tags the cover with the time it last changed', () => { + expect(coverUrl({ id: 'ada', coverUpdatedAt: '2026-01-02T03:04:05Z' })) + .toBe('/api/books/ada/cover?v=2026-01-02T03:04:05Z') + }) + + it('still answers for a book that has never had its cover replaced', () => { + expect(coverUrl({ id: 'ada' })).toBe('/api/books/ada/cover?v=') + }) +}) + +describe('audiobookFileUrl', () => { + it('tags the file with the time it was generated', () => { + expect(audiobookFileUrl('ada', '2026-01-02T03:04:05Z')) + .toBe('/api/books/ada/audiobook/file?v=2026-01-02T03%3A04%3A05Z') + }) + + it('omits the tag entirely when nothing has been generated yet', () => { + expect(audiobookFileUrl('ada')).toBe('/api/books/ada/audiobook/file') + }) +}) + +describe('voicePreviewUrl', () => { + it('addresses a voice sample by id', () => { + expect(voicePreviewUrl('am_michael')).toBe('/api/audiobook/voices/am_michael/preview') + }) +}) diff --git a/client/api/urls.ts b/client/api/urls.ts new file mode 100644 index 0000000..0ba656e --- /dev/null +++ b/client/api/urls.ts @@ -0,0 +1,33 @@ +import { apiUrl } from './http' + +/** + * URLs for the media the browser fetches on our behalf. + * + * Everything else in this zone goes through fetch, so it can carry headers and + * report failures. These three are handed to an img tag or an Audio element + * instead, which means the URL itself has to carry everything, including the + * version tag that keeps a regenerated file from being served from cache. + */ + +/** + * Cover image for a book, tagged with the moment the cover last changed. + * + * The tag is deliberately not percent encoded here while the audiobook one + * below is. Both are only cache keys that the server ignores, and normalising + * them would change every cover URL in every install for no gain, so the + * difference is left as it is on purpose rather than by oversight. + */ +export function coverUrl(book: { id: string; coverUpdatedAt?: string }): string { + return apiUrl(`/api/books/${book.id}/cover?v=${book.coverUpdatedAt ?? ''}`) +} + +/** Concatenated audiobook for a book, tagged with the moment it was generated. */ +export function audiobookFileUrl(bookId: string, generatedAt?: string): string { + const version = generatedAt ? `?v=${encodeURIComponent(generatedAt)}` : '' + return apiUrl(`/api/books/${bookId}/audiobook/file${version}`) +} + +/** Short spoken sample of a narrator voice, used when choosing one. */ +export function voicePreviewUrl(voiceId: string): string { + return apiUrl(`/api/audiobook/voices/${voiceId}/preview`) +} diff --git a/client/app/App.tsx b/client/app/App.tsx new file mode 100644 index 0000000..214d320 --- /dev/null +++ b/client/app/App.tsx @@ -0,0 +1,117 @@ +import { LibraryPage } from '@client/features/library/LibraryPage' +import { ReaderPage } from '@client/features/reader/ReaderPage' +import { QuizReviewPage } from '@client/features/quiz/QuizReviewPage' +import { ReviewProgressPage } from '@client/features/progress/ReviewProgressPage' +import { SkillDetailPage } from '@client/features/progress/SkillDetailPage' +import { ProfileUpdatePage } from '@client/features/profile/ProfileUpdatePage' +import { CreationView } from '@client/features/creation/components/CreationView' +import { useBooks } from '@client/features/library/hooks/useBooks' +import { useHealthCheck } from '@client/features/library/hooks/useHealthCheck' +import { useElectronApiKeys } from '@client/features/library/hooks/useElectronApiKeys' +import { useBackgroundTaskEffects } from '@client/features/library/hooks/useBackgroundTaskEffects' +import { useLibraryNavigation } from '@client/features/library/hooks/useLibraryNavigation' +import { persistor, useAppDispatch, setLastViewedBookId } from '@client/store' + +export default function App() { + const { books, setBooks, hasLoaded, fetchBooks, addOptimisticBook } = useBooks() + const serverAvailable = useHealthCheck() + useElectronApiKeys() + const audiobook = useBackgroundTaskEffects({ books, fetchBooks }) + const nav = useLibraryNavigation({ books, setBooks, hasLoaded, fetchBooks }) + const dispatch = useAppDispatch() + const { view } = nav + + if (view.type === 'creating') { + return ( + + ) + } + + if (view.type === 'resuming') { + return ( + + ) + } + + if (view.type === 'reading') { + return ( + { + dispatch(setLastViewedBookId(null)) + persistor.flush().catch(() => {}) + fetchBooks() + nav.goToLibrary() + }} + onQuizReview={() => nav.goToQuizReview(view.book)} + onUpdateProfile={() => nav.goToProfileUpdate(view.book.id, view.book.title)} + /> + ) + } + + if (view.type === 'quiz-review') { + return ( + { fetchBooks(); nav.goToLibrary() }} + onBackToReader={() => nav.goToReading(view.book)} + /> + ) + } + + if (view.type === 'review-progress') { + return ( + + ) + } + + if (view.type === 'skill-detail') { + return ( + + ) + } + + if (view.type === 'profile-update') { + return ( + { fetchBooks(); nav.goToLibrary() }} + /> + ) + } + + return ( + + ) +} diff --git a/client/main.tsx b/client/app/main.tsx similarity index 72% rename from client/main.tsx rename to client/app/main.tsx index 9680970..7100384 100644 --- a/client/main.tsx +++ b/client/app/main.tsx @@ -2,12 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' -import { store, persistor } from './store' -import { ThemeProvider } from './components/ThemeProvider' -import { initApiBase } from './lib/api-base' +import { store, persistor } from '@client/store' +import { ThemeProvider } from '@client/features/settings/components/ThemeProvider' +import { initApiBase } from '@client/api/http' import { Toaster } from 'sonner' -import App from './App' -import './index.css' +import App from '@client/app/App' +import '@client/index.css' initApiBase().then(() => { createRoot(document.getElementById('root')!).render( diff --git a/client/components/PageTurnOverlay.tsx b/client/components/PageTurnOverlay.tsx deleted file mode 100644 index 816b3a7..0000000 --- a/client/components/PageTurnOverlay.tsx +++ /dev/null @@ -1,100 +0,0 @@ -interface PageTurnOverlayProps { - progress: number - direction: 'next' | 'prev' | null - isAnimating: boolean - children: React.ReactNode - nextPreview?: React.ReactNode - prevPreview?: React.ReactNode -} - -export function PageTurnOverlay({ - progress, - direction, - isAnimating, - children, - nextPreview, - prevPreview, -}: PageTurnOverlayProps) { - const isActive = direction !== null && progress > 0 - - if (!isActive) { - return <>{children} - } - - const willChange = isAnimating ? 'transform, opacity' : undefined - - if (direction === 'next') { - return ( -
- {/* Next chapter preview (behind) */} - {nextPreview && ( -
- {nextPreview} - {/* Dark overlay that fades out */} -
-
- )} - - {/* Current page (rotates away) */} -
- {children} - {/* Shadow on the folding edge */} -
-
-
- ) - } - - // direction === 'prev' - return ( -
- {/* Prev chapter preview (slides down from top) */} - {prevPreview && ( -
- {prevPreview} -
- )} - - {/* Current page (slides down) */} -
- {children} -
-
- ) -} diff --git a/client/components/SettingsMenu.tsx b/client/components/SettingsMenu.tsx deleted file mode 100644 index 8d2bb99..0000000 --- a/client/components/SettingsMenu.tsx +++ /dev/null @@ -1,573 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { Settings, Sun, Moon, Monitor, Type, Layers, Check, CheckCircle2, User, BarChart3, Sliders, MoveHorizontal, ListOrdered, BookOpen, Headphones, Trash2 } from 'lucide-react' -import { Button } from '@client/components/ui/button' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@client/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuItem, - DropdownMenuGroup, -} from '@client/components/ui/dropdown-menu' -import { TickSlider } from '@client/components/ui/tick-slider' -import { ModelAssignmentDialog } from '@client/components/ModelAssignmentDialog' -import { ProfileDialog } from '@client/components/ProfileDialog' -import { InterviewPanel } from '@client/components/InterviewPanel' -import { SkillsPanel } from '@client/components/SkillsPanel' -import { AudiobookSettingsDialog } from '@client/components/AudiobookSettingsDialog' -import { useTheme } from '@client/components/ThemeProvider' -import { - useAppDispatch, - useAppSelector, - selectHasApiKey, - selectActiveProvider, - selectProviders, - selectFontSize, - selectReadingWidth, - selectQuizLength, - selectTextureEnabled, - selectTextureOpacity, - selectDefaultChapterCount, - setActiveProvider, - setProviderApiKey, - setFontSize, - setReadingWidth, - READING_WIDTHS, - DEFAULT_READING_WIDTH, - setQuizLength, - setDefaultChapterCount, - setTextureEnabled, - setTextureOpacity, - selectModelAssignmentSeen, - setModelAssignmentSeen, -} from '@client/store' -import { PROVIDERS, PROVIDER_IDS, type ProviderId } from '@client/lib/providers' -import { apiUrl } from '@client/lib/api-base' - -const CHAPTER_COUNTS = [1, 3, 6, 12, 25, 50] -const CHAPTER_LABELS = ['Essay', 'Short', 'Novella', 'Standard', 'Long', 'Epic'] -const DEFAULT_CHAPTER_COUNT = 12 - -const FONT_SIZES = [12, 13, 14, 15, 16, 17, 18, 20, 22] -const DEFAULT_FONT_SIZE = 16 - -const READING_WIDTH_LABELS = ['Narrow', 'Medium', 'Default', 'Wide', 'Extra Wide', 'Full'] - -interface SettingsMenuProps { - apiKeyDialogOpen?: boolean - onApiKeyDialogClose?: () => void - onReviewProgress?: () => void - subtle?: boolean -} - -export function SettingsMenu({ apiKeyDialogOpen, onApiKeyDialogClose, onReviewProgress, subtle }: SettingsMenuProps = {}) { - const { theme, setTheme } = useTheme() - const dispatch = useAppDispatch() - const hasApiKey = useAppSelector(selectHasApiKey) - const activeProvider = useAppSelector(selectActiveProvider) - const providers = useAppSelector(selectProviders) - const fontSize = useAppSelector(selectFontSize) - const readingWidth = useAppSelector(selectReadingWidth) - const quizLength = useAppSelector(selectQuizLength) - const defaultChapterCount = useAppSelector(selectDefaultChapterCount) - const textureEnabled = useAppSelector(selectTextureEnabled) - const textureOpacity = useAppSelector(selectTextureOpacity) - const modelAssignmentSeen = useAppSelector(selectModelAssignmentSeen) - - const [profileOpen, setProfileOpen] = useState(false) - const [interviewOpen, setInterviewOpen] = useState(false) - const [skillsOpen, setSkillsOpen] = useState(false) - const [modelAssignOpen, setModelAssignOpen] = useState(false) - const [audiobookSettingsOpen, setAudiobookSettingsOpen] = useState(false) - const [internalDialogOpen, setInternalDialogOpen] = useState(false) - const [dialogProvider, setDialogProvider] = useState(activeProvider) - const [keyInputs, setKeyInputs] = useState>>({}) - const [profileConfigured, setProfileConfigured] = useState(null) - const apiKeyInputRef = useRef(null) - - // Check if learning profile has been set up - useEffect(() => { - fetch(apiUrl('/api/profile')) - .then(res => res.json()) - .then(data => { - setProfileConfigured(!!data.aboutMe?.trim()) - }) - .catch(() => setProfileConfigured(false)) - }, []) - - // Re-check after interview or profile dialog closes - useEffect(() => { - if (!profileOpen && !interviewOpen) { - fetch(apiUrl('/api/profile')) - .then(res => res.json()) - .then(data => setProfileConfigured(!!data.aboutMe?.trim())) - .catch(() => {}) - } - }, [profileOpen, interviewOpen]) - - const needsApiKey = !hasApiKey - const needsProfile = profileConfigured === false - const hasAnyBadge = needsApiKey || needsProfile - - const dialogOpen = internalDialogOpen || (apiKeyDialogOpen ?? false) - const setDialogOpen = (open: boolean) => { - setInternalDialogOpen(open) - if (!open) onApiKeyDialogClose?.() - } - - useEffect(() => { - if (apiKeyDialogOpen) { - setDialogProvider(activeProvider) - setKeyInputs({}) - } - }, [apiKeyDialogOpen]) // eslint-disable-line react-hooks/exhaustive-deps - - const openDialog = () => { - setDialogProvider(activeProvider) - setKeyInputs({}) - setDialogOpen(true) - } - - const handleSelectDialogProvider = (id: ProviderId) => { - setDialogProvider(id) - } - - useEffect(() => { - if (!dialogOpen) return - const t = setTimeout(() => apiKeyInputRef.current?.focus(), 0) - return () => clearTimeout(t) - }, [dialogOpen, dialogProvider]) - - const saveTimeoutsRef = useRef>>>({}) - - const persistProviderKey = async (provider: ProviderId, key: string) => { - const trimmed = key.trim() - if (!trimmed) return - await window.electronAPI?.saveApiKey(trimmed, provider) - try { - await fetch(apiUrl('/api/settings/api-key'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, apiKey: trimmed }), - }) - } catch { /* server may not be ready */ } - dispatch(setProviderApiKey({ provider, apiKey: trimmed })) - } - - const handleKeyInputChange = (provider: ProviderId, value: string) => { - setKeyInputs(prev => ({ ...prev, [provider]: value })) - const existing = saveTimeoutsRef.current[provider] - if (existing) clearTimeout(existing) - saveTimeoutsRef.current[provider] = setTimeout(() => { - persistProviderKey(provider, value) - }, 200) - } - - const handleRemove = async (provider: ProviderId) => { - await window.electronAPI?.removeApiKey(provider) - try { - await fetch(apiUrl('/api/settings/api-key'), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider }), - }) - } catch { /* server may not be ready */ } - dispatch(setProviderApiKey({ provider, apiKey: null })) - setKeyInputs(prev => { - const next = { ...prev } - delete next[provider] - return next - }) - } - - const activeDef = PROVIDERS[activeProvider] - const activeModel = providers[activeProvider]?.model - const activeModelLabel = activeDef.models.find(m => m.value === activeModel)?.label ?? activeModel - - const chapterCountIndex = CHAPTER_COUNTS.indexOf(defaultChapterCount) - const defaultChapterIndex = CHAPTER_COUNTS.indexOf(DEFAULT_CHAPTER_COUNT) - const chapterCountLabel = CHAPTER_LABELS[chapterCountIndex >= 0 ? chapterCountIndex : defaultChapterIndex] - - const fontSizeIndex = FONT_SIZES.indexOf(fontSize) - const defaultIndex = FONT_SIZES.indexOf(DEFAULT_FONT_SIZE) - const readingWidthIndex = (READING_WIDTHS as readonly number[]).indexOf(readingWidth) - const defaultWidthIndex = READING_WIDTHS.indexOf(DEFAULT_READING_WIDTH) - const readingWidthLabel = READING_WIDTH_LABELS[readingWidthIndex >= 0 ? readingWidthIndex : defaultWidthIndex] - - const dialogDef = PROVIDERS[dialogProvider] - const dialogConfig = providers[dialogProvider] - - return ( - <> - - - } - > - - {hasAnyBadge && !subtle && ( - - )} - - - - {/* Provider / API Key */} - - - {activeDef.label.slice(0, 2).toUpperCase()} - - {hasApiKey ? ( - <> - {activeDef.name} - {activeModelLabel} - - ) : ( - <> - AI Provider - - Not set - - - - )} - - - {hasApiKey && ( - <> - { dispatch(setModelAssignmentSeen(true)); setModelAssignOpen(true) }}> - - Model Assignment - {!modelAssignmentSeen && ( - - New - - )} - - - - )} - - setProfileOpen(true)}> - - Learning Profile - {needsProfile && ( - - Not set - - - )} - - - onReviewProgress?.()}> - - Review Progress - - - setAudiobookSettingsOpen(true)}> - - Audiobook narration - - - - - {/* Quiz Length */} -
-
- - Quiz Length - {quizLength} -
- dispatch(setQuizLength(v))} - onPointerDown={e => e.stopPropagation()} - ticks={Array.from({ length: 10 }, (_, i) => ({ - highlight: i + 1 === 3, - label: i + 1 === 3 ? 'default' : undefined, - }))} - /> -
- - - - {/* Default Chapter Count */} -
-
- - Default Book Length - {defaultChapterCount} · {chapterCountLabel} -
- = 0 ? chapterCountIndex : defaultChapterIndex} - onChange={v => dispatch(setDefaultChapterCount(CHAPTER_COUNTS[v]))} - onPointerDown={e => e.stopPropagation()} - ticks={CHAPTER_COUNTS.map((_, i) => ({ - highlight: i === defaultChapterIndex, - label: i === defaultChapterIndex ? 'default' : undefined, - }))} - /> -
- - - - {/* Theme */} - - Theme - setTheme(v as 'light' | 'dark' | 'system')}> - - - Light - - - - Dark - - - - System - - - - - - - {/* Font Size */} -
-
- - Font Size - {fontSize}px -
- = 0 ? fontSizeIndex : defaultIndex} - onChange={v => dispatch(setFontSize(FONT_SIZES[v]))} - onPointerDown={e => e.stopPropagation()} - ticks={FONT_SIZES.map((_, i) => ({ - highlight: i === defaultIndex, - label: i === defaultIndex ? 'default' : undefined, - }))} - /> -
- - - - {/* Reading Width */} -
-
- - Reading Width - {readingWidthLabel} -
- = 0 ? readingWidthIndex : defaultWidthIndex} - onChange={v => dispatch(setReadingWidth(READING_WIDTHS[v]))} - onPointerDown={e => e.stopPropagation()} - ticks={READING_WIDTHS.map((_, i) => ({ - highlight: i === defaultWidthIndex, - label: i === defaultWidthIndex ? 'default' : undefined, - }))} - /> -
- - - - {/* Texture */} -
-
- - Texture - -
- {textureEnabled && ( -
- dispatch(setTextureOpacity(parseInt(e.target.value) / 100))} - className="w-full cursor-pointer" - style={{ '--range-fill': `${Math.round(textureOpacity * 100)}%` } as React.CSSProperties} - onPointerDown={e => e.stopPropagation()} - /> -
- Subtle - Heavy -
-
- )} -
-
-
- - {/* Provider settings dialog */} - - - - AI Provider - - Configure API keys for each provider independently. - - - - {/* Default provider selector */} - {PROVIDER_IDS.some(id => !!providers[id]?.apiKey) && ( -
- - -
- )} - - {/* Provider tabs */} -
- {PROVIDER_IDS.map(id => { - const def = PROVIDERS[id] - const hasKey = !!providers[id]?.apiKey - const isSelected = dialogProvider === id - const isActive = activeProvider === id && hasKey - return ( - - ) - })} -
- -
-
- -
- handleKeyInputChange(dialogProvider, e.target.value)} - placeholder={dialogConfig?.apiKey ? 'Key saved (enter new to replace)' : dialogDef.placeholder} - className={`h-9 w-full rounded-lg border border-border-default bg-surface-raised px-3 ${dialogConfig?.apiKey ? 'pr-9' : ''} font-mono text-sm text-content-primary placeholder:text-content-muted/50 outline-none transition-colors focus:border-border-focus focus:ring-2 focus:ring-border-focus/20`} - /> - {dialogConfig?.apiKey && ( - - )} -
-
-
- - - - -
-
- - { - setProfileOpen(false) - setInterviewOpen(true) - }} - onOpenSkills={() => { - setProfileOpen(false) - setSkillsOpen(true) - }} - /> - - { - setInterviewOpen(false) - if (profileUpdated) { - setProfileOpen(true) - } - }} - onMissingApiKey={openDialog} - /> - - { - setSkillsOpen(false) - setProfileOpen(true) - }} - /> - - - - - - ) -} diff --git a/client/components/AudiobookDownloadModal.tsx b/client/features/audiobook/components/AudiobookDownloadModal.tsx similarity index 100% rename from client/components/AudiobookDownloadModal.tsx rename to client/features/audiobook/components/AudiobookDownloadModal.tsx diff --git a/client/components/AudiobookRegenerateConfirmModal.tsx b/client/features/audiobook/components/AudiobookRegenerateConfirmModal.tsx similarity index 100% rename from client/components/AudiobookRegenerateConfirmModal.tsx rename to client/features/audiobook/components/AudiobookRegenerateConfirmModal.tsx diff --git a/client/components/AudiobookSettingsDialog.tsx b/client/features/audiobook/components/AudiobookSettingsDialog.tsx similarity index 83% rename from client/components/AudiobookSettingsDialog.tsx rename to client/features/audiobook/components/AudiobookSettingsDialog.tsx index b305bbd..34a1416 100644 --- a/client/components/AudiobookSettingsDialog.tsx +++ b/client/features/audiobook/components/AudiobookSettingsDialog.tsx @@ -11,34 +11,11 @@ import { DialogTitle, DialogDescription, } from '@client/components/ui/dialog' -import { apiUrl } from '@client/lib/api-base' +import { getProfile, saveProfile, voicePreviewUrl, type Skill } from '@client/api' +import { useAudiobookEngine } from '@client/features/audiobook/hooks/useAudiobookEngine' import { type Preferences, DEFAULT_PREFS } from '@client/lib/profile-constants' - -interface VoiceInfo { - id: string - name: string - language: 'American English' | 'British English' - gender: 'Male' | 'Female' - grade: string -} - -interface AudiobookPrefs { - defaultVoiceId: string - defaultSpeed: number - workerOverride?: number -} - -interface ProfileResponse { - aboutMe: string - preferences: Preferences & { audiobook?: AudiobookPrefs } - skills: unknown[] -} - -interface AudiobookStatus { - installed: boolean - missing: { model: boolean; ffmpeg: boolean } - downloadSize: number -} +import type { AudiobookPreferences } from '@shared/domain' +import type { VoiceInfo } from '@shared/responses' interface AudiobookSettingsDialogProps { open: boolean @@ -63,16 +40,14 @@ function sortVoicesByGender(voices: VoiceInfo[]): VoiceInfo[] { export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSettingsDialogProps) { const [loaded, setLoaded] = useState(false) const [aboutMe, setAboutMe] = useState('') - const [skills, setSkills] = useState([]) + const [skills, setSkills] = useState([]) const [preferences, setPreferences] = useState(DEFAULT_PREFS) - const [status, setStatus] = useState(null) - const [voices, setVoices] = useState([]) + const { status, voices, installing, loadStatus, loadVoices, install } = useAudiobookEngine() const [selectedVoice, setSelectedVoice] = useState(DEFAULT_VOICE) const [speed, setSpeed] = useState(DEFAULT_SPEED) const [workerOverride, setWorkerOverride] = useState('') const [previewing, setPreviewing] = useState(false) const [saving, setSaving] = useState(false) - const [installing, setInstalling] = useState(false) const audioRef = useRef(null) useEffect(() => { @@ -83,18 +58,11 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting let cancelled = false ;(async () => { try { - const [profileRes, statusRes, voicesRes] = await Promise.all([ - fetch(apiUrl('/api/profile')), - fetch(apiUrl('/api/audiobook/status')), - fetch(apiUrl('/api/audiobook/voices')), + const [profile, , voicesData] = await Promise.all([ + getProfile(), + loadStatus(), + loadVoices(), ]) - if (!profileRes.ok) throw new Error(`Profile fetch failed: ${profileRes.status}`) - if (!statusRes.ok) throw new Error(`Status fetch failed: ${statusRes.status}`) - if (!voicesRes.ok) throw new Error(`Voices fetch failed: ${voicesRes.status}`) - - const profile = (await profileRes.json()) as ProfileResponse - const statusData = (await statusRes.json()) as AudiobookStatus - const voicesData = (await voicesRes.json()) as { voices: VoiceInfo[] } if (cancelled) return @@ -102,13 +70,11 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting setSkills(profile.skills ?? []) const prefs = { ...DEFAULT_PREFS, ...profile.preferences } setPreferences(prefs) - setStatus(statusData) - setVoices(voicesData.voices) const audiobook = profile.preferences?.audiobook const desiredVoice = audiobook?.defaultVoiceId ?? DEFAULT_VOICE - const voiceExists = voicesData.voices.some(v => v.id === desiredVoice) - setSelectedVoice(voiceExists ? desiredVoice : voicesData.voices[0]?.id ?? DEFAULT_VOICE) + const voiceExists = voicesData.some(v => v.id === desiredVoice) + setSelectedVoice(voiceExists ? desiredVoice : voicesData[0]?.id ?? DEFAULT_VOICE) setSpeed(audiobook?.defaultSpeed ?? DEFAULT_SPEED) setWorkerOverride(audiobook?.workerOverride != null ? String(audiobook.workerOverride) : '') setLoaded(true) @@ -123,7 +89,7 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting return () => { cancelled = true } - }, [open]) + }, [open, loadStatus, loadVoices]) useEffect(() => { if (open) return @@ -157,7 +123,7 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting setPreviewing(true) audioRef.current?.pause() try { - const audio = new Audio(apiUrl(`/api/audiobook/voices/${selectedVoice}/preview`)) + const audio = new Audio(voicePreviewUrl(selectedVoice)) audioRef.current = audio audio.addEventListener('ended', () => setPreviewing(false), { once: true }) audio.addEventListener( @@ -177,20 +143,11 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting const handleInstall = async (isReinstall: boolean) => { if (installing) return - setInstalling(true) try { // v1 limitation: there is no separate "force/redownload" endpoint. The // install task only fetches components reported as missing, so a true // wipe-and-redownload would need a new server endpoint. - const res = await fetch(apiUrl('/api/audiobook/install'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - if (!res.ok && res.status !== 409) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || `Install failed: ${res.status}`) - } + await install() toast.success( isReinstall ? "Checking narrator components — we'll redownload anything missing." @@ -200,12 +157,10 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting toast.error( 'Failed to start install: ' + (err instanceof Error ? err.message : 'Unknown error'), ) - } finally { - setInstalling(false) } } - const validate = (): AudiobookPrefs | null => { + const validate = (): AudiobookPreferences | null => { if (!voices.some(v => v.id === selectedVoice)) { toast.error('Pick a valid voice') return null @@ -238,15 +193,7 @@ export function AudiobookSettingsDialog({ open, onOpenChange }: AudiobookSetting setSaving(true) try { const nextPreferences = { ...preferences, audiobook } - const res = await fetch(apiUrl('/api/profile'), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ aboutMe, preferences: nextPreferences, skills }), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || `Save failed: ${res.status}`) - } + await saveProfile({ aboutMe, preferences: nextPreferences, skills }) toast.success('Audiobook settings saved.') onOpenChange(false) } catch (err) { diff --git a/client/components/AudiobookVoiceModal.tsx b/client/features/audiobook/components/AudiobookVoiceModal.tsx similarity index 88% rename from client/components/AudiobookVoiceModal.tsx rename to client/features/audiobook/components/AudiobookVoiceModal.tsx index 7ad0ad6..a209923 100644 --- a/client/components/AudiobookVoiceModal.tsx +++ b/client/features/audiobook/components/AudiobookVoiceModal.tsx @@ -11,15 +11,9 @@ import { DialogTitle, DialogDescription, } from '@client/components/ui/dialog' -import { apiUrl } from '@client/lib/api-base' - -interface VoiceInfo { - id: string - name: string - language: 'American English' | 'British English' - gender: 'Male' | 'Female' - grade: string -} +import { generateAudiobook, voicePreviewUrl } from '@client/api' +import { useAudiobookEngine } from '@client/features/audiobook/hooks/useAudiobookEngine' +import type { VoiceInfo } from '@shared/responses' interface AudiobookVoiceModalProps { open: boolean @@ -79,7 +73,7 @@ export function AudiobookVoiceModal({ rememberAsDefaultByDefault = true, mode, }: AudiobookVoiceModalProps) { - const [voices, setVoices] = useState([]) + const { voices, loadVoices } = useAudiobookEngine() const [selectedVoice, setSelectedVoice] = useState(DEFAULT_VOICE) const [speed, setSpeed] = useState(1.0) const [rememberAsDefault, setRememberAsDefault] = useState(rememberAsDefaultByDefault) @@ -93,14 +87,11 @@ export function AudiobookVoiceModal({ let cancelled = false ;(async () => { try { - const res = await fetch(apiUrl('/api/audiobook/voices')) - if (!res.ok) throw new Error(`Failed: ${res.status}`) - const data = (await res.json()) as { voices: VoiceInfo[] } + const list = await loadVoices() if (cancelled) return - setVoices(data.voices) // Keep DEFAULT_VOICE if available, otherwise pick first. - if (!data.voices.some(v => v.id === DEFAULT_VOICE) && data.voices[0]) { - setSelectedVoice(data.voices[0].id) + if (!list.some(v => v.id === DEFAULT_VOICE) && list[0]) { + setSelectedVoice(list[0].id) } } catch (err) { if (cancelled) return @@ -110,7 +101,7 @@ export function AudiobookVoiceModal({ return () => { cancelled = true } - }, [open]) + }, [open, loadVoices]) // Stop any preview audio when the modal closes or unmounts. useEffect(() => { @@ -141,7 +132,7 @@ export function AudiobookVoiceModal({ setPreviewing(true) audioRef.current?.pause() try { - const audio = new Audio(apiUrl(`/api/audiobook/voices/${selectedVoice}/preview`)) + const audio = new Audio(voicePreviewUrl(selectedVoice)) audioRef.current = audio audio.addEventListener('ended', () => setPreviewing(false), { once: true }) audio.addEventListener('error', () => { @@ -159,20 +150,12 @@ export function AudiobookVoiceModal({ if (submitting) return setSubmitting(true) try { - const res = await fetch(apiUrl(`/api/books/${bookId}/audiobook`), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - voiceId: selectedVoice, - speed, - rememberAsDefault, - confirmReplace: mode === 'regenerate', - }), + await generateAudiobook(bookId, { + voiceId: selectedVoice, + speed, + rememberAsDefault, + confirmReplace: mode === 'regenerate', }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || `Request failed: ${res.status}`) - } toast.success('Audiobook generation started — check background tasks') onOpenChange(false) } catch (err) { diff --git a/client/features/audiobook/hooks/useAudiobookEngine.ts b/client/features/audiobook/hooks/useAudiobookEngine.ts new file mode 100644 index 0000000..fcca72f --- /dev/null +++ b/client/features/audiobook/hooks/useAudiobookEngine.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from 'react' +import { getEngineStatus, installEngine, listVoices } from '@client/api' +import type { AudiobookStatus, VoiceInfo } from '@shared/responses' + +/** + * The narration engine's install status and its available voices, owned in + * one place so the audiobook settings dialog and the voice picker modal + * don't each hand-roll their own fetch-and-store logic for the same three + * concerns: engine status, install, and voice listing. + * + * Loading stays imperative rather than effect-driven here. Each caller + * combines this data with its own concerns on its own open-gated effect — + * the settings dialog derives its initial voice choice from the learning + * profile, loaded alongside status and voices in one Promise.all — so this + * hook only owns what to fetch and where the result lives, not when. + */ +export function useAudiobookEngine() { + const [status, setStatus] = useState(null) + const [voices, setVoices] = useState([]) + const [installing, setInstalling] = useState(false) + + const loadStatus = useCallback(async () => { + const result = await getEngineStatus() + setStatus(result) + return result + }, []) + + const loadVoices = useCallback(async () => { + const result = await listVoices() + setVoices(result) + return result + }, []) + + const install = useCallback(async () => { + setInstalling(true) + try { + await installEngine() + } finally { + setInstalling(false) + } + }, []) + + return { status, voices, installing, loadStatus, loadVoices, install } +} diff --git a/client/hooks/useChapterAudio.ts b/client/features/audiobook/hooks/useChapterAudio.ts similarity index 64% rename from client/hooks/useChapterAudio.ts rename to client/features/audiobook/hooks/useChapterAudio.ts index c05b019..76d0da5 100644 --- a/client/hooks/useChapterAudio.ts +++ b/client/features/audiobook/hooks/useChapterAudio.ts @@ -1,24 +1,14 @@ import { useEffect, useState, useCallback } from 'react' -import { apiUrl } from '@client/lib/api-base' +import { getBookAudiobook, type BookAudiobookStatus } from '@client/api' +import { AUDIOBOOK_POLL_MS } from '@client/lib/constants' import { useAppSelector, selectRunningTasks } from '@client/store' -interface AudiobookStatus { - exists: boolean - generatedChapters: number[] - manifest: { - voice: string - speed: number - generatedAt: string - chapters: Array<{ num: number; title: string; startSec: number; durationSec: number }> - } | null -} - // Shared lookup so every chapter button doesn't fire its own HTTP call. // Re-polls every few seconds while an audiobook task is running for this // book so per-chapter Listen buttons light up progressively as the WAV->MP3 // conversion completes for each chapter. export function useChapterAudio(bookId: string) { - const [status, setStatus] = useState(null) + const [status, setStatus] = useState(null) const runningTasks = useAppSelector(selectRunningTasks) const audiobookRunning = runningTasks.some( t => t.bookId === bookId && t.type === 'generate-audiobook', @@ -26,14 +16,8 @@ export function useChapterAudio(bookId: string) { const refresh = useCallback(async () => { try { - const res = await fetch(apiUrl(`/api/books/${bookId}/audiobook`)) - if (!res.ok) { setStatus(null); return } - const data = await res.json() - setStatus({ - exists: !!data.exists, - generatedChapters: data.generatedChapters ?? [], - manifest: data.manifest ?? null, - }) + const data = await getBookAudiobook(bookId) + setStatus(data) } catch { setStatus(null) } @@ -49,7 +33,7 @@ export function useChapterAudio(bookId: string) { void refresh() return } - const interval = setInterval(() => { void refresh() }, 4000) + const interval = setInterval(() => { void refresh() }, AUDIOBOOK_POLL_MS) return () => clearInterval(interval) }, [audiobookRunning, refresh]) diff --git a/client/components/ChatMessage.tsx b/client/features/chat/components/ChatMessage.tsx similarity index 92% rename from client/components/ChatMessage.tsx rename to client/features/chat/components/ChatMessage.tsx index 881277e..875c99b 100644 --- a/client/components/ChatMessage.tsx +++ b/client/features/chat/components/ChatMessage.tsx @@ -1,5 +1,5 @@ -import { SafeMarkdown } from '@client/components/SafeMarkdown' -import type { ChatMessage as ChatMessageType } from '@client/hooks/useStreamingChat' +import { SafeMarkdown } from '@client/features/markdown/SafeMarkdown' +import type { ChatMessage as ChatMessageType } from '@client/features/chat/hooks/useStreamingChat' interface ChatMessageProps { message: ChatMessageType diff --git a/client/components/ChatPanel.tsx b/client/features/chat/components/ChatPanel.tsx similarity index 98% rename from client/components/ChatPanel.tsx rename to client/features/chat/components/ChatPanel.tsx index f28ca23..f5aa540 100644 --- a/client/components/ChatPanel.tsx +++ b/client/features/chat/components/ChatPanel.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { X, SendHorizontal, Copy, ClipboardCopy } from 'lucide-react' -import { ChatMessage } from '@client/components/ChatMessage' +import { ChatMessage } from '@client/features/chat/components/ChatMessage' import { toast } from '@client/lib/toast' -import { useStreamingChat } from '@client/hooks/useStreamingChat' +import { useStreamingChat } from '@client/features/chat/hooks/useStreamingChat' import { useAppDispatch, useAppSelector, selectHasApiKeyForFunction, selectFunctionModel, setChatMessages, selectChatMessages } from '@client/store' interface ChatPanelProps { @@ -245,6 +245,7 @@ export function ChatPanel({ open, onClose, selectedText, chapterContent, initial +
+