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.
This commit is contained in:
Richard R 2026-05-13 17:39:04 -06:00
parent fdeeb60f7e
commit a296d6fb13
2 changed files with 167 additions and 161 deletions

View file

@ -14,6 +14,7 @@ import {
Button, Button,
Input, Input,
} from '@headlessui/react'; } from '@headlessui/react';
import { useQueryClient } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
@ -110,8 +111,17 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
]; ];
type AdminSubTab = 'providers' | 'features'; type AdminSubTab = 'providers' | 'features';
const LIBRARY_DOCUMENTS_QUERY_KEY = ['documents-library', 10000] as const;
async function fetchLibraryDocuments(): Promise<BaseDocument[]> {
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 }) { export function SettingsModal({ className = '' }: { className?: string }) {
const queryClient = useQueryClient();
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels; const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
@ -157,6 +167,13 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const router = useRouter(); const router = useRouter();
const isBusy = isImportingLibrary; 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 { providers: sharedProviders } = useSharedProviders();
const { const {
@ -280,16 +297,19 @@ export function SettingsModal({ className = '' }: { className?: string }) {
}; };
const handleImportLibrary = async () => { 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({ setSelectionModalProps({
title: 'Import from Library', title: 'Import from Library',
confirmLabel: 'Import', confirmLabel: 'Import',
defaultSelected: false, defaultSelected: false,
fetcher: async () => { initialFiles: queryClient.getQueryData(LIBRARY_DOCUMENTS_QUERY_KEY),
const res = await fetch('/api/documents/library?limit=10000'); fetcher: fetchLibraryDocumentsCached,
if (!res.ok) throw new Error('Failed to list library documents');
const data = await res.json();
return data.documents || [];
}
}); });
setIsSelectionModalOpen(true); setIsSelectionModalOpen(true);
}; };

View file

@ -2,6 +2,7 @@
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'; import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs'; import type { Book } from 'epubjs';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
@ -39,19 +40,16 @@ interface SegmentsSidebarProps {
epubBookRef?: RefObject<Book | null>; epubBookRef?: RefObject<Book | null>;
} }
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 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 { function formatDuration(ms: number | null | undefined): string {
if (!ms || !Number.isFinite(ms) || ms <= 0) return '—'; 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) { export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: SegmentsSidebarProps) {
const queryClient = useQueryClient();
const { const {
sentences, sentences,
currentSentenceIndex, currentSentenceIndex,
@ -236,11 +235,102 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]); }, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
const [state, setState] = useState<FetchState>({ kind: 'idle' });
const [isClearing, setIsClearing] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const didAutoScrollOnOpenRef = useRef(false); 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<TTSSegmentRow[]>((acc, page) => mergeRows(acc, page.segments), []);
}, [manifestData]);
const clearSegmentsMutation = useMutation({
mutationFn: async (): Promise<ClearSegmentsPayload | null> => {
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<TTSSegmentSettings>(() => ({ const activeSettings = useMemo<TTSSegmentSettings>(() => ({
providerRef, providerRef,
@ -251,116 +341,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
ttsInstructions: ttsInstructions || '', ttsInstructions: ttsInstructions || '',
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, 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 () => { 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.'); const confirmed = window.confirm('Clear cached segments for this document version? This removes stored segment metadata and audio objects.');
if (!confirmed) return; if (!confirmed) return;
await clearSegments();
setIsClearing(true); }, [documentId, isClearingSegments, clearSegments]);
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]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@ -368,18 +354,15 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
if (!node) return; if (!node) return;
const onScroll = () => { const onScroll = () => {
setState((prev) => { if (!hasMoreManifestPages || isLoadingMoreManifest) return;
if (prev.kind !== 'ready' || !prev.hasMore || prev.loadingMore) return prev; const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
const distance = node.scrollHeight - node.scrollTop - node.clientHeight; if (distance > 280) return;
if (distance > 280) return prev; void fetchNextPage();
void loadManifest('append', prev.nextCursor);
return { ...prev, loadingMore: true };
});
}; };
node.addEventListener('scroll', onScroll); node.addEventListener('scroll', onScroll);
return () => node.removeEventListener('scroll', onScroll); return () => node.removeEventListener('scroll', onScroll);
}, [isOpen, loadManifest]); }, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]);
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
if (!settings) return; if (!settings) return;
@ -395,8 +378,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
didAutoScrollOnOpenRef.current = false; didAutoScrollOnOpenRef.current = false;
void loadManifest('reset'); void queryClient.invalidateQueries({ queryKey: segmentsQueryKey });
}, [loadManifest]); void refetchManifest();
}, [queryClient, segmentsQueryKey, refetchManifest]);
const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => { const handleJump = useCallback((index: number, locator: TTSSegmentLocator | null) => {
playFromSegment(index, locator); playFromSegment(index, locator);
@ -420,7 +404,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
*/ */
isSynthesized: boolean; isSynthesized: boolean;
}; };
if (state.kind !== 'ready') return [] as Entry[]; if (!manifestData) return [] as Entry[];
// Fallback locator for the live viewport. Used when per-sentence // Fallback locator for the live viewport. Used when per-sentence
// canonical resolution hasn't completed yet and for PDF/HTML, which don't // 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 // collapses the visible duplicates (same content showing up twice — once
// as a synth row with sentence text, once as a manifest row with audio). // as a synth row with sentence text, once as a manifest row with audio).
const manifestBySegmentKey = new Map<string, TTSSegmentRow>(); const manifestBySegmentKey = new Map<string, TTSSegmentRow>();
for (const row of state.data) { for (const row of manifestRows) {
if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row); 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 // screen) render as their own listings. They keep their original locator
// and group, so they sort into their chapter bucket at their real // and group, so they sort into their chapter bucket at their real
// `charOffset` position. // `charOffset` position.
for (const row of state.data) { for (const row of manifestRows) {
if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue; if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue;
entries.push({ entries.push({
segmentIndex: row.segmentIndex, segmentIndex: row.segmentIndex,
@ -545,11 +529,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
}); });
return entries; return entries;
}, [state, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]); }, [manifestData, manifestRows, currDocPage, currDocPageNumber, sentences, epubBookRef, documentId, activeReaderType, synthRowCanonical]);
const totalVariants = state.kind === 'ready' const totalVariants = rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0);
? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0)
: 0; const hasLoadedManifest = !!manifestData;
const isManifestLoading = isManifestPending && !manifestData;
const manifestErrorMessage = manifestError instanceof Error ? manifestError.message : 'Failed to load';
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -557,7 +543,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
return; return;
} }
if (didAutoScrollOnOpenRef.current) return; if (didAutoScrollOnOpenRef.current) return;
if (state.kind !== 'ready' || rowsToRender.length === 0) return; if (!hasLoadedManifest || rowsToRender.length === 0) return;
const container = listRef.current; const container = listRef.current;
if (!container) return; if (!container) return;
@ -568,11 +554,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
didAutoScrollOnOpenRef.current = true; didAutoScrollOnOpenRef.current = true;
}); });
}, [isOpen, state.kind, rowsToRender.length, currentSentenceIndex]); }, [isOpen, hasLoadedManifest, rowsToRender.length, currentSentenceIndex]);
useEffect(() => { useEffect(() => {
if (!isOpen || !isPlaying) return; if (!isOpen || !isPlaying) return;
if (state.kind !== 'ready' || rowsToRender.length === 0) return; if (!hasLoadedManifest || rowsToRender.length === 0) return;
const root = listRef.current; const root = listRef.current;
if (!root) return; if (!root) return;
@ -589,7 +575,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
}, [ }, [
isOpen, isOpen,
isPlaying, isPlaying,
state.kind, hasLoadedManifest,
rowsToRender.length, rowsToRender.length,
currentSentenceIndex, currentSentenceIndex,
currDocPage, currDocPage,
@ -603,10 +589,10 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
onClick={() => void handleClearCache()} onClick={() => void handleClearCache()}
aria-label="Clear segments cache" aria-label="Clear segments cache"
title="Clear cache for listed segments" 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" 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'}
</button> </button>
<button <button
type="button" type="button"
@ -632,21 +618,21 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
> >
<div className="px-4 py-2 border-b border-offbase"> <div className="px-4 py-2 border-b border-offbase">
<div className="text-xs text-muted"> <div className="text-xs text-muted">
{state.kind === 'ready' ? ( {hasLoadedManifest ? (
<> <>
{rowsToRender.length} indexed {rowsToRender.length} indexed
<span> · </span> <span> · </span>
{totalVariants} variants {totalVariants} variants
{state.hasMore ? ( {hasMoreManifestPages ? (
<> <>
<span> · </span> <span> · </span>
more more
</> </>
) : null} ) : null}
</> </>
) : state.kind === 'loading' ? ( ) : isManifestLoading ? (
<span>Loading</span> <span>Loading</span>
) : state.kind === 'error' ? ( ) : hasManifestError ? (
<span className="text-red-500">error</span> <span className="text-red-500">error</span>
) : ( ) : (
<span></span> <span></span>
@ -655,13 +641,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
</div> </div>
<div ref={listRef} className="flex-1 overflow-y-auto"> <div ref={listRef} className="flex-1 overflow-y-auto">
{state.kind === 'error' && ( {hasManifestError && (
<div className="px-4 py-6 text-sm text-red-500">{state.message}</div> <div className="px-4 py-6 text-sm text-red-500">{manifestErrorMessage}</div>
)} )}
{state.kind === 'loading' && ( {isManifestLoading && (
<div className="px-4 py-6 text-sm text-muted">Loading segments</div> <div className="px-4 py-6 text-sm text-muted">Loading segments</div>
)} )}
{state.kind === 'ready' && rowsToRender.length === 0 && ( {hasLoadedManifest && rowsToRender.length === 0 && (
<div className="px-4 py-10 flex flex-col items-center text-center gap-2"> <div className="px-4 py-10 flex flex-col items-center text-center gap-2">
<div className="text-sm font-medium text-muted"> <div className="text-sm font-medium text-muted">
No segments No segments
@ -671,7 +657,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
</p> </p>
</div> </div>
)} )}
{state.kind === 'ready' && rowsToRender.length > 0 && ( {hasLoadedManifest && rowsToRender.length > 0 && (
<ul className="divide-y divide-offbase"> <ul className="divide-y divide-offbase">
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => { {rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => {
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null; const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
@ -800,7 +786,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
})} })}
</ul> </ul>
)} )}
{state.kind === 'ready' && state.loadingMore && ( {hasLoadedManifest && isLoadingMoreManifest && (
<div className="px-4 py-3 text-xs text-muted">Loading more segments</div> <div className="px-4 py-3 text-xs text-muted">Loading more segments</div>
)} )}
</div> </div>