diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f099662e..d6d9d69d 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1367,21 +1367,18 @@ function App() { const requestPayloads: CreateRequestPayload[] = []; - if (ebookMode === 'download') { - if (!ebookRelease) { - throw new Error('Missing ebook release for combined download'); - } + if (ebookMode === 'download' && ebookRelease) { await executeReleaseDownload(book, ebookRelease, 'ebook', onBehalfOfUserId); - } else { + } else if (ebookMode !== 'download' && (ebookRelease || ebookMode === 'request_book')) { requestPayloads.push(buildRequestPayload(ebookRelease, 'ebook', ebookMode)); } - if (audiobookMode === 'download') { - if (!audiobookRelease) { - throw new Error('Missing audiobook release for combined download'); - } + if (audiobookMode === 'download' && audiobookRelease) { await executeReleaseDownload(book, audiobookRelease, 'audiobook', onBehalfOfUserId); - } else { + } else if ( + audiobookMode !== 'download' && + (audiobookRelease || audiobookMode === 'request_book') + ) { requestPayloads.push(buildRequestPayload(audiobookRelease, 'audiobook', audiobookMode)); } @@ -1695,7 +1692,7 @@ function App() { // Combined mode callbacks const handleCombinedNext = useCallback( - (release: Release) => { + (release: Release | null) => { if (!releaseBook || !combinedState) return; const phases = getCombinedSelectionPhases(combinedState); const nextPhase = phases[phases.indexOf(combinedState.phase) + 1]; @@ -1703,7 +1700,7 @@ function App() { setCombinedState({ ...combinedState, phase: nextPhase, - stagedEbook: { book: releaseBook, release }, + stagedEbook: release ? { book: releaseBook, release } : undefined, }); }, [combinedState, getCombinedSelectionPhases, releaseBook], @@ -1715,19 +1712,31 @@ function App() { ); }, []); + const handleCombinedClearSelection = useCallback((selectionContentType: ContentType) => { + setCombinedState((prev) => { + if (!prev) { + return null; + } + if (selectionContentType === 'ebook') { + return { ...prev, stagedEbook: undefined }; + } + return { ...prev, stagedAudiobook: undefined }; + }); + }, []); + const handleCombinedDownload = useCallback( - async (release: Release) => { + async (release: Release | null) => { if (!combinedState || !releaseBook) return; const nextCombinedState: CombinedSelectionState = combinedState.phase === 'ebook' ? { ...combinedState, - stagedEbook: { book: releaseBook, release }, + stagedEbook: release ? { book: releaseBook, release } : undefined, } : { ...combinedState, - stagedAudiobook: release, + stagedAudiobook: release ?? undefined, }; if (effectiveActingAsUser) { @@ -2603,6 +2612,7 @@ function App() { stagedAudiobookRelease: effectiveCombinedState.stagedAudiobook ?? null, onNext: !combinedIsFinalStep ? handleCombinedNext : undefined, onBack: combinedHasPreviousStep ? handleCombinedBack : undefined, + onClearSelection: handleCombinedClearSelection, onDownload: combinedIsFinalStep ? (release) => { void handleCombinedDownload(release); diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index fa79b0f3..30bd42e0 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -60,16 +60,26 @@ interface CombinedModeConfig { audiobookMode: RequestPolicyMode; stagedEbookRelease: Release | null; stagedAudiobookRelease: Release | null; - onNext?: (release: Release) => void; + onNext?: (release: Release | null) => void; onBack?: (audiobookRelease: Release | null) => void; - onDownload?: (release: Release) => void; + onDownload?: (release: Release | null) => void; + onClearSelection?: (contentType: ContentType) => void; } // Determine the combined download button label based on action modes function getCombinedDownloadLabel( ebookMode: RequestPolicyMode | null | undefined, audiobookMode: RequestPolicyMode | null | undefined, + hasEbookAction = true, + hasAudiobookAction = true, ): string { + if (hasEbookAction && !hasAudiobookAction) { + return getSingleCombinedActionLabel('ebook', ebookMode); + } + if (!hasEbookAction && hasAudiobookAction) { + return getSingleCombinedActionLabel('audiobook', audiobookMode); + } + const ebookIsRequest = ebookMode === 'request_release' || ebookMode === 'request_book'; const audiobookIsRequest = audiobookMode === 'request_release' || audiobookMode === 'request_book'; @@ -78,6 +88,15 @@ function getCombinedDownloadLabel( return 'Download Both'; } +function getSingleCombinedActionLabel( + contentType: ContentType, + mode: RequestPolicyMode | null | undefined, +): string { + const noun = contentType === 'ebook' ? 'Book' : 'Audiobook'; + const isRequest = mode === 'request_release' || mode === 'request_book'; + return `${isRequest ? 'Request' : 'Download'} ${noun}`; +} + // Default column configuration (fallback when backend doesn't provide one) const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = { columns: [ @@ -147,6 +166,7 @@ const STAR_POSITIONS = [0, 1, 2, 3, 4] as const; interface ReleaseModalSessionProps extends Omit { book: Book; isClosing: boolean; + animateEnter: boolean; onClose: () => void; } @@ -286,10 +306,12 @@ const PhaseChip = ({ release, isActive, label, + onClear, }: { release: Release | null; isActive: boolean; label: string; + onClear?: () => void; }) => { let phaseChipClassName = 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500'; if (release) { @@ -299,28 +321,68 @@ const PhaseChip = ({ phaseChipClassName = 'bg-zinc-100 text-(--text) dark:bg-zinc-800'; } + const selectedIcon = onClear ? ( + + + + + + + + + ) : ( + + + + ); + + const chipContent = release ? ( + <> + {selectedIcon} + {release.format?.toUpperCase() || label} · {release.size || '?'} + + ) : ( + <> + {isActive ? '\u25CF' : '\u25CB'} {label} + + ); + + if (release && onClear) { + return ( + + ); + } + return ( - {release ? ( - <> - - - - {release.format?.toUpperCase() || label} · {release.size || '?'} - - ) : ( - <> - {isActive ? '\u25CF' : '\u25CB'} {label} - - )} + {chipContent} ); }; @@ -689,6 +751,7 @@ const ReleaseModalSession = ({ onShowToast, combinedMode = null, isClosing, + animateEnter, }: ReleaseModalSessionProps) => { // Use audiobook formats when in audiobook mode const effectiveFormats = @@ -713,6 +776,7 @@ const ReleaseModalSession = ({ const onCombinedNext = combinedMode?.onNext; const onCombinedBack = combinedMode?.onBack; const onCombinedDownload = combinedMode?.onDownload; + const onCombinedClearSelection = combinedMode?.onClearSelection; const handleClose = onClose; @@ -1192,6 +1256,55 @@ const ReleaseModalSession = ({ combinedFooterAudiobookMode = getReleaseActionMode(stagedAudiobookRelease); } + const hasCombinedEbookAction = + combinedPhase === 'ebook' + ? selectedRelease !== null + : stagedEbookRelease !== null || combinedFooterEbookMode === 'request_book'; + const hasCombinedAudiobookAction = + combinedPhase === 'audiobook' + ? selectedRelease !== null + : stagedAudiobookRelease !== null || combinedFooterAudiobookMode === 'request_book'; + const hasCombinedActionOutsideCurrentPhase = + combinedPhase === 'ebook' ? hasCombinedAudiobookAction : hasCombinedEbookAction; + const canCompleteCombinedAction = + selectedRelease !== null || hasCombinedActionOutsideCurrentPhase; + const currentCombinedPhaseLabel = combinedPhase === 'ebook' ? 'Book' : 'Audiobook'; + const nextCombinedPhaseLabel = combinedPhase === 'ebook' ? 'Audiobook' : 'Book'; + let emptyStateMessage = 'No releases found for this book.'; + if (formatFilter) { + emptyStateMessage = `No ${formatFilter.toUpperCase()} releases found. Try a different format.`; + } else if (isCombinedMode) { + emptyStateMessage = `No ${currentCombinedPhaseLabel.toLowerCase()} releases found.`; + } + let modalAnimationClassName = ''; + if (isClosing) { + modalAnimationClassName = 'settings-modal-exit'; + } else if (animateEnter) { + modalAnimationClassName = 'settings-modal-enter'; + } else { + modalAnimationClassName = 'release-modal-no-step-animation'; + } + const canClearEbookSelection = + combinedPhase === 'ebook' ? selectedRelease !== null : stagedEbookRelease !== null; + const canClearAudiobookSelection = + combinedPhase === 'audiobook' ? selectedRelease !== null : stagedAudiobookRelease !== null; + const clearEbookSelection = canClearEbookSelection + ? () => { + if (combinedPhase === 'ebook') { + setSelectedRelease(null); + } + onCombinedClearSelection?.('ebook'); + } + : undefined; + const clearAudiobookSelection = canClearAudiobookSelection + ? () => { + if (combinedPhase === 'audiobook') { + setSelectedRelease(null); + } + onCombinedClearSelection?.('audiobook'); + } + : undefined; + const modal = (
)} {onCombinedDownload && ( )} @@ -2211,6 +2332,7 @@ const ReleaseModalSession = ({ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => { const [isClosing, setIsClosing] = useState(false); + const previousSessionKeyRef = useRef(null); const handleClose = useCallback(() => { setIsClosing(true); @@ -2223,18 +2345,26 @@ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => { useBodyScrollLock(Boolean(book)); useEscapeKey(Boolean(book), handleClose); + const sessionKey = book + ? [ + book.id, + rest.defaultShowManualQuery ? 'manual' : 'auto', + book.search_title || '', + book.title || '', + book.search_author || '', + book.author || '', + ].join('|') + : null; + + const animateEnter = + !rest.combinedMode || + previousSessionKeyRef.current === null || + previousSessionKeyRef.current === sessionKey; + + previousSessionKeyRef.current = sessionKey; + if (!book && !isClosing) return null; - if (!book) return null; - - const sessionKey = [ - book.id, - rest.contentType, - rest.defaultShowManualQuery ? 'manual' : 'auto', - book.search_title || '', - book.title || '', - book.search_author || '', - book.author || '', - ].join('|'); + if (!book || !sessionKey) return null; return ( { book={book} onClose={handleClose} isClosing={isClosing} + animateEnter={animateEnter} {...rest} /> ); diff --git a/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts b/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts index 402a8bc8..d12bbc2b 100644 --- a/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts +++ b/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts @@ -19,7 +19,7 @@ import { invalidateCachedReleases, setCachedReleases, } from '../../utils/releaseCache'; -import { useMountEffect } from '../useMountEffect'; +import { useDependencyEffect, useMountEffect } from '../useMountEffect'; interface ReleaseModalTabInfo { name: string; @@ -325,10 +325,6 @@ export function useReleaseSearchSession( ], ); useMountEffect(() => { - if (!book) { - return undefined; - } - let cancelled = false; const fetchSources = async () => { @@ -339,25 +335,7 @@ export function useReleaseSearchSession( if (cancelled) { return; } - setAvailableSources(sources); - - const tabs = buildReleaseTabs( - sources, - book.provider, - contentType, - preferredDefaultReleaseSource, - ); - const initialActiveTab = initialActiveTabRef.current; - const nextActiveTab = tabs.some((tab) => tab.name === initialActiveTab) - ? initialActiveTab - : (tabs[0]?.name ?? ''); - - setActiveTabState(nextActiveTab); - - if (nextActiveTab) { - void fetchReleaseResults(nextActiveTab, { force: false }); - } } catch (err) { console.error('Failed to fetch release sources:', err); if (!cancelled) { @@ -377,6 +355,60 @@ export function useReleaseSearchSession( }; }); + useDependencyEffect(() => { + if (sourcesLoading) { + return undefined; + } + + indexerFilterInitializedRef.current = new Set(); + const nextInitialActiveTab = preferredDefaultReleaseSource || ''; + initialActiveTabRef.current = nextInitialActiveTab; + activeTabRef.current = nextInitialActiveTab; + pendingStatusRef.current = null; + lastStatusTimeRef.current = 0; + if (statusTimeoutRef.current) { + clearTimeout(statusTimeoutRef.current); + statusTimeoutRef.current = null; + } + + const tabs = buildReleaseTabs( + availableSources, + book.provider, + contentType, + preferredDefaultReleaseSource, + ); + const nextActiveTab = tabs.some((tab) => tab.name === nextInitialActiveTab) + ? nextInitialActiveTab + : (tabs[0]?.name ?? ''); + + setActiveTabState(nextActiveTab); + setReleasesBySource({}); + setLoadingBySource({}); + setErrorBySource({}); + setExpandedBySource({}); + setSearchStatus(null); + setFormatFilter(''); + setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); + setIndexerFilter([]); + setManualQuery(defaultShowManualQuery ? defaultManualQuery : ''); + setShowManualQuery(defaultShowManualQuery); + + if (nextActiveTab) { + void fetchReleaseResults(nextActiveTab, { force: true }); + } + + return undefined; + }, [ + availableSources, + book.id, + book.provider, + contentType, + defaultManualQuery, + defaultShowManualQuery, + preferredDefaultReleaseSource, + sourcesLoading, + ]); + useMountEffect(() => { if (!book || !socket) { return undefined; diff --git a/src/frontend/src/hooks/useMountEffect.ts b/src/frontend/src/hooks/useMountEffect.ts index 4b875abc..c15b34f5 100644 --- a/src/frontend/src/hooks/useMountEffect.ts +++ b/src/frontend/src/hooks/useMountEffect.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, type EffectCallback } from 'react'; +import { useEffect, useRef, type DependencyList, type EffectCallback } from 'react'; export function useMountEffect(effect: EffectCallback): void { const effectRef = useRef(effect); @@ -6,3 +6,11 @@ export function useMountEffect(effect: EffectCallback): void { useEffect(() => effectRef.current(), []); } + +export function useDependencyEffect(effect: EffectCallback, deps: DependencyList): void { + const effectRef = useRef(effect); + effectRef.current = effect; + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => effectRef.current(), deps); +} diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 945f79c6..840034c2 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -589,6 +589,11 @@ will-change: transform, opacity; } + .release-modal-no-step-animation, + .release-modal-no-step-animation * { + animation: none !important; + } + .settings-modal-exit { animation: settings-modal-exit 0.15s ease-in; -webkit-backface-visibility: hidden;