From a296d6fb13a8819c016c9430831940b6cc525875 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 17:39:04 -0600 Subject: [PATCH] feat(reader,settings): add react-query caching for library import and TTS segments Integrate @tanstack/react-query in SettingsModal to cache and prefetch library documents, improving import modal responsiveness. Refactor SegmentsSidebar to use infiniteQuery for TTS segments manifest, enabling pagination, cache invalidation, and consistent data fetching. Remove legacy fetch state logic in favor of react-query state management. --- src/components/SettingsModal.tsx | 32 ++- src/components/reader/SegmentsSidebar.tsx | 296 +++++++++++----------- 2 files changed, 167 insertions(+), 161 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 82a3292..85b2936 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -14,6 +14,7 @@ import { Button, Input, } from '@headlessui/react'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; @@ -110,8 +111,17 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [ ]; type AdminSubTab = 'providers' | 'features'; +const LIBRARY_DOCUMENTS_QUERY_KEY = ['documents-library', 10000] as const; + +async function fetchLibraryDocuments(): Promise { + const res = await fetch('/api/documents/library?limit=10000'); + if (!res.ok) throw new Error('Failed to list library documents'); + const data = (await res.json()) as { documents?: BaseDocument[] }; + return data.documents || []; +} export function SettingsModal({ className = '' }: { className?: string }) { + const queryClient = useQueryClient(); const runtimeConfig = useRuntimeConfig(); const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels; @@ -157,6 +167,13 @@ export function SettingsModal({ className = '' }: { className?: string }) { const { data: session } = useAuthSession(); const router = useRouter(); const isBusy = isImportingLibrary; + const fetchLibraryDocumentsCached = useCallback(async () => { + return queryClient.ensureQueryData({ + queryKey: LIBRARY_DOCUMENTS_QUERY_KEY, + queryFn: fetchLibraryDocuments, + staleTime: 60 * 1000, + }); + }, [queryClient]); const { providers: sharedProviders } = useSharedProviders(); const { @@ -280,16 +297,19 @@ export function SettingsModal({ className = '' }: { className?: string }) { }; const handleImportLibrary = async () => { + // Start fetching as soon as the user opens the picker so cached data is + // often ready before the modal asks for it. + void queryClient.prefetchQuery({ + queryKey: LIBRARY_DOCUMENTS_QUERY_KEY, + queryFn: fetchLibraryDocuments, + staleTime: 60 * 1000, + }); setSelectionModalProps({ title: 'Import from Library', confirmLabel: 'Import', defaultSelected: false, - fetcher: async () => { - const res = await fetch('/api/documents/library?limit=10000'); - if (!res.ok) throw new Error('Failed to list library documents'); - const data = await res.json(); - return data.documents || []; - } + initialFiles: queryClient.getQueryData(LIBRARY_DOCUMENTS_QUERY_KEY), + fetcher: fetchLibraryDocumentsCached, }); setIsSelectionModalOpen(true); }; diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 6994b8c..5219932 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -2,6 +2,7 @@ import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { Book } from 'epubjs'; import toast from 'react-hot-toast'; import { useTTS } from '@/contexts/TTSContext'; @@ -39,19 +40,16 @@ interface SegmentsSidebarProps { epubBookRef?: RefObject; } -type FetchState = - | { kind: 'idle' } - | { kind: 'loading' } - | { - kind: 'ready'; - data: TTSSegmentRow[]; - nextCursor: string | null; - hasMore: boolean; - loadingMore: boolean; - } - | { kind: 'error'; message: string }; - const MANIFEST_PAGE_SIZE = 150; +const SEGMENTS_MANIFEST_QUERY_KEY = 'tts-segments-manifest'; + +type ClearSegmentsPayload = { + error?: string; + deletedSegments?: number; + requestedAudioObjects?: number; + deletedAudioObjects?: number; + warning?: string; +}; function formatDuration(ms: number | null | undefined): string { if (!ms || !Number.isFinite(ms) || ms <= 0) return '—'; @@ -174,6 +172,7 @@ function isElementFullyVisibleWithinContainer(element: HTMLElement, container: H } export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: SegmentsSidebarProps) { + const queryClient = useQueryClient(); const { sentences, currentSentenceIndex, @@ -236,11 +235,102 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: return () => { cancelled = true; }; }, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]); - const [state, setState] = useState({ kind: 'idle' }); - const [isClearing, setIsClearing] = useState(false); - const abortRef = useRef(null); const listRef = useRef(null); const didAutoScrollOnOpenRef = useRef(false); + const segmentsQueryKey = useMemo( + () => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const, + [documentId], + ); + + const segmentsQuery = useInfiniteQuery({ + queryKey: segmentsQueryKey, + enabled: isOpen && !!documentId, + initialPageParam: null as string | null, + queryFn: async ({ pageParam, signal }) => { + if (!documentId) { + return { + documentId: '', + segments: [], + hasMore: false, + nextCursor: null, + } satisfies TTSSegmentsManifestResponse; + } + const cursor = typeof pageParam === 'string' && pageParam.length > 0 ? pageParam : null; + const params = new URLSearchParams({ + documentId, + limit: String(MANIFEST_PAGE_SIZE), + }); + if (cursor) params.set('cursor', cursor); + const res = await fetch(`/api/tts/segments/manifest?${params.toString()}`, { + signal, + cache: 'no-store', + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + // If a stale cursor becomes invalid between page fetches, stop + // pagination for this query instead of surfacing a hard error. + if (cursor && body.toLowerCase().includes('invalid cursor')) { + return { + documentId, + segments: [], + hasMore: false, + nextCursor: null, + } satisfies TTSSegmentsManifestResponse; + } + throw new Error(body || `Request failed (${res.status})`); + } + return (await res.json()) as TTSSegmentsManifestResponse; + }, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined), + }); + const { + data: manifestData, + isPending: isManifestPending, + isError: hasManifestError, + error: manifestError, + hasNextPage: hasMoreManifestPages, + isFetchingNextPage: isLoadingMoreManifest, + fetchNextPage, + refetch: refetchManifest, + } = segmentsQuery; + + const manifestRows = useMemo(() => { + const pages = manifestData?.pages ?? []; + if (pages.length === 0) return [] as TTSSegmentRow[]; + return pages.reduce((acc, page) => mergeRows(acc, page.segments), []); + }, [manifestData]); + + const clearSegmentsMutation = useMutation({ + mutationFn: async (): Promise => { + if (!documentId) throw new Error('Missing document id'); + const res = await fetch('/api/tts/segments/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId }), + }); + const payload = (await res.json().catch(() => null)) as ClearSegmentsPayload | null; + if (!res.ok) { + throw new Error(payload?.error || `Request failed (${res.status})`); + } + return payload; + }, + onSuccess: async (payload) => { + if (payload?.warning) { + toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`); + } else if (payload) { + const deletedSegments = Number(payload.deletedSegments ?? 0); + const deletedAudioObjects = Number(payload.deletedAudioObjects ?? 0); + const requestedAudioObjects = Number(payload.requestedAudioObjects ?? deletedAudioObjects); + toast.success(`Cleared ${deletedSegments} segments and ${deletedAudioObjects}/${requestedAudioObjects} audio objects.`); + } + await queryClient.invalidateQueries({ queryKey: segmentsQueryKey }); + await queryClient.refetchQueries({ queryKey: segmentsQueryKey, type: 'active' }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache'); + }, + }); + const { mutateAsync: clearSegments, isPending: isClearingSegments } = clearSegmentsMutation; const activeSettings = useMemo(() => ({ providerRef, @@ -251,116 +341,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: ttsInstructions: ttsInstructions || '', }), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]); - const loadManifest = useCallback(async ( - mode: 'reset' | 'append' = 'reset', - cursorOverride: string | null = null, - ) => { - if (!documentId) return; - const cursor = mode === 'append' ? cursorOverride : null; - if (mode === 'append' && !cursor) return; - - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - if (mode === 'reset') { - setState({ kind: 'loading' }); - } else { - setState((prev) => { - if (prev.kind !== 'ready') return prev; - return { ...prev, loadingMore: true }; - }); - } - try { - const params = new URLSearchParams({ - documentId, - limit: String(MANIFEST_PAGE_SIZE), - }); - if (cursor) params.set('cursor', cursor); - const res = await fetch( - `/api/tts/segments/manifest?${params.toString()}`, - { signal: controller.signal, cache: 'no-store' }, - ); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(body || `Request failed (${res.status})`); - } - const data = (await res.json()) as TTSSegmentsManifestResponse; - setState((prev) => { - const merged = mode === 'append' && prev.kind === 'ready' - ? mergeRows(prev.data, data.segments) - : mergeRows([], data.segments); - return { - kind: 'ready', - data: merged, - nextCursor: data.nextCursor, - hasMore: data.hasMore, - loadingMore: false, - }; - }); - } catch (err) { - if (controller.signal.aborted) return; - if (mode === 'append' && err instanceof Error && err.message.includes('Invalid cursor')) { - setState((prev) => { - if (prev.kind !== 'ready') return prev; - return { - ...prev, - loadingMore: false, - hasMore: false, - nextCursor: null, - }; - }); - return; - } - setState({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load' }); - } - }, [documentId]); - const handleClearCache = useCallback(async () => { - if (!documentId || isClearing) return; + if (!documentId || isClearingSegments) return; const confirmed = window.confirm('Clear cached segments for this document version? This removes stored segment metadata and audio objects.'); if (!confirmed) return; - - setIsClearing(true); - try { - const res = await fetch('/api/tts/segments/clear', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ documentId }), - }); - const payload = (await res.json().catch(() => null)) as { - error?: string; - deletedSegments?: number; - requestedAudioObjects?: number; - deletedAudioObjects?: number; - warning?: string; - } | null; - if (!res.ok) { - throw new Error(payload?.error || `Request failed (${res.status})`); - } - - if (payload?.warning) { - toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`); - } else if (payload) { - const deletedSegments = Number(payload.deletedSegments ?? 0); - const deletedAudioObjects = Number(payload.deletedAudioObjects ?? 0); - const requestedAudioObjects = Number(payload.requestedAudioObjects ?? deletedAudioObjects); - toast.success(`Cleared ${deletedSegments} segments and ${deletedAudioObjects}/${requestedAudioObjects} audio objects.`); - } - await loadManifest('reset'); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to clear segments cache'); - } finally { - setIsClearing(false); - } - }, [documentId, isClearing, loadManifest]); - - useEffect(() => { - if (!isOpen) return; - void loadManifest('reset'); - return () => { - abortRef.current?.abort(); - }; - }, [isOpen, loadManifest]); + await clearSegments(); + }, [documentId, isClearingSegments, clearSegments]); useEffect(() => { if (!isOpen) return; @@ -368,18 +354,15 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: if (!node) return; const onScroll = () => { - setState((prev) => { - if (prev.kind !== 'ready' || !prev.hasMore || prev.loadingMore) return prev; - const distance = node.scrollHeight - node.scrollTop - node.clientHeight; - if (distance > 280) return prev; - void loadManifest('append', prev.nextCursor); - return { ...prev, loadingMore: true }; - }); + if (!hasMoreManifestPages || isLoadingMoreManifest) return; + const distance = node.scrollHeight - node.scrollTop - node.clientHeight; + if (distance > 280) return; + void fetchNextPage(); }; node.addEventListener('scroll', onScroll); return () => node.removeEventListener('scroll', onScroll); - }, [isOpen, loadManifest]); + }, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]); const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { if (!settings) return; @@ -395,8 +378,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const handleRefresh = useCallback(() => { didAutoScrollOnOpenRef.current = false; - void loadManifest('reset'); - }, [loadManifest]); + void queryClient.invalidateQueries({ queryKey: segmentsQueryKey }); + void refetchManifest(); + }, [queryClient, segmentsQueryKey, refetchManifest]); const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => { playFromSegment(index, locator); @@ -420,7 +404,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: */ isSynthesized: boolean; }; - if (state.kind !== 'ready') return [] as Entry[]; + if (!manifestData) return [] as Entry[]; // Fallback locator for the live viewport. Used when per-sentence // canonical resolution hasn't completed yet and for PDF/HTML, which don't @@ -460,7 +444,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: // collapses the visible duplicates (same content showing up twice — once // as a synth row with sentence text, once as a manifest row with audio). const manifestBySegmentKey = new Map(); - for (const row of state.data) { + for (const row of manifestRows) { if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row); } @@ -521,7 +505,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: // screen) render as their own listings. They keep their original locator // and group, so they sort into their chapter bucket at their real // `charOffset` position. - for (const row of state.data) { + for (const row of manifestRows) { if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue; entries.push({ segmentIndex: row.segmentIndex, @@ -545,11 +529,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: }); return entries; - }, [state, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]); + }, [manifestData, manifestRows, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]); - const totalVariants = state.kind === 'ready' - ? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0) - : 0; + const totalVariants = rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0); + + const hasLoadedManifest = !!manifestData; + const isManifestLoading = isManifestPending && !manifestData; + const manifestErrorMessage = manifestError instanceof Error ? manifestError.message : 'Failed to load'; useEffect(() => { if (!isOpen) { @@ -557,7 +543,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: return; } if (didAutoScrollOnOpenRef.current) return; - if (state.kind !== 'ready' || rowsToRender.length === 0) return; + if (!hasLoadedManifest || rowsToRender.length === 0) return; const container = listRef.current; if (!container) return; @@ -568,11 +554,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); didAutoScrollOnOpenRef.current = true; }); - }, [isOpen, state.kind, rowsToRender.length, currentSentenceIndex]); + }, [isOpen, hasLoadedManifest, rowsToRender.length, currentSentenceIndex]); useEffect(() => { if (!isOpen || !isPlaying) return; - if (state.kind !== 'ready' || rowsToRender.length === 0) return; + if (!hasLoadedManifest || rowsToRender.length === 0) return; const root = listRef.current; if (!root) return; @@ -589,7 +575,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: }, [ isOpen, isPlaying, - state.kind, + hasLoadedManifest, rowsToRender.length, currentSentenceIndex, currDocPage, @@ -603,10 +589,10 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: onClick={() => void handleClearCache()} aria-label="Clear segments cache" title="Clear cache for listed segments" - disabled={isClearing} + disabled={isClearingSegments} className="inline-flex items-center justify-center h-8 px-2 rounded-lg border border-offbase bg-base text-xs text-muted hover:bg-offbase hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > - {isClearing ? 'Clearing…' : 'Clear'} + {isClearingSegments ? 'Clearing…' : 'Clear'}