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'}