Improved combined search behavior (#910)

- Added functionality to continue with a combined search if one option
is unfulfilled
- Added option to deselect currently selected release

Fixes #890
This commit is contained in:
Alex
2026-04-23 20:56:32 +01:00
committed by GitHub
parent 791ba184ee
commit 8bba0e28d7
5 changed files with 268 additions and 82 deletions
+25 -15
View File
@@ -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);
+174 -43
View File
@@ -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<ReleaseModalProps, 'book' | 'onClose'> {
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 ? (
<span className="relative h-3 w-3 shrink-0">
<svg
className="absolute inset-0 h-3 w-3 opacity-100 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<svg
className="absolute inset-0 h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</span>
) : (
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const chipContent = release ? (
<>
{selectedIcon}
{release.format?.toUpperCase() || label} · {release.size || '?'}
</>
) : (
<>
{isActive ? '\u25CF' : '\u25CB'} {label}
</>
);
if (release && onClear) {
return (
<button
type="button"
onClick={onClear}
className={`group inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-900/20 dark:hover:text-red-300 ${phaseChipClassName}`}
aria-label={`Remove selected ${label.toLowerCase()}`}
>
{chipContent}
</button>
);
}
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${phaseChipClassName}`}
>
{release ? (
<>
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{release.format?.toUpperCase() || label} · {release.size || '?'}
</>
) : (
<>
{isActive ? '\u25CF' : '\u25CB'} {label}
</>
)}
{chipContent}
</span>
);
};
@@ -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 = (
<div className="modal-overlay active sm:px-6 sm:py-6">
<button
@@ -1201,7 +1314,7 @@ const ReleaseModalSession = ({
aria-label="Close release modal"
/>
<div
className={`details-container relative z-10 h-full w-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container relative z-10 h-full w-full sm:h-auto ${modalAnimationClassName}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
@@ -2022,13 +2135,7 @@ const ReleaseModalSession = ({
if (filteredReleases.length === 0 && !currentTabLoading) {
return (
<>
<EmptyState
message={
formatFilter
? `No ${formatFilter.toUpperCase()} releases found. Try a different format.`
: 'No releases found for this book.'
}
/>
<EmptyState message={emptyStateMessage} />
{/* Action button - plugin-defined or default expand search */}
{(columnConfig.action_button ||
(!expandedBySource[activeTab] &&
@@ -2137,6 +2244,7 @@ const ReleaseModalSession = ({
release={combinedPhase === 'ebook' ? selectedRelease : stagedEbookRelease}
isActive={combinedPhase === 'ebook'}
label="Book"
onClear={clearEbookSelection}
/>
<PhaseChip
release={
@@ -2144,6 +2252,7 @@ const ReleaseModalSession = ({
}
isActive={combinedPhase === 'audiobook'}
label="Audiobook"
onClear={clearAudiobookSelection}
/>
</div>
@@ -2171,25 +2280,37 @@ const ReleaseModalSession = ({
const picked = selectedRelease;
setSelectedRelease(stagedAudiobookRelease);
onCombinedNext(picked);
} else {
setSelectedRelease(stagedAudiobookRelease);
onCombinedNext(null);
}
}}
disabled={!selectedRelease}
className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Select Audiobook &rarr;
{selectedRelease
? `Select ${nextCombinedPhaseLabel}`
: `Skip ${currentCombinedPhaseLabel}`}
</button>
)}
{onCombinedDownload && (
<button
type="button"
onClick={() => selectedRelease && onCombinedDownload(selectedRelease)}
disabled={!selectedRelease}
onClick={() => {
if (selectedRelease) {
onCombinedDownload(selectedRelease);
} else if (canCompleteCombinedAction) {
onCombinedDownload(null);
}
}}
disabled={!canCompleteCombinedAction}
className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{getCombinedDownloadLabel(
combinedFooterEbookMode,
combinedFooterAudiobookMode,
hasCombinedEbookAction,
hasCombinedAudiobookAction,
)}
</button>
)}
@@ -2211,6 +2332,7 @@ const ReleaseModalSession = ({
export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const previousSessionKeyRef = useRef<string | null>(null);
const handleClose = useCallback(() => {
setIsClosing(true);
@@ -2223,18 +2345,26 @@ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
useBodyScrollLock(Boolean(book));
useEscapeKey(Boolean(book), handleClose);
if (!book && !isClosing) return null;
if (!book) return null;
const sessionKey = book
? [
book.id,
rest.defaultShowManualQuery ? 'manual' : 'auto',
book.search_title || '',
book.title || '',
book.search_author || '',
book.author || '',
].join('|')
: null;
const sessionKey = [
book.id,
rest.contentType,
rest.defaultShowManualQuery ? 'manual' : 'auto',
book.search_title || '',
book.title || '',
book.search_author || '',
book.author || '',
].join('|');
const animateEnter =
!rest.combinedMode ||
previousSessionKeyRef.current === null ||
previousSessionKeyRef.current === sessionKey;
previousSessionKeyRef.current = sessionKey;
if (!book && !isClosing) return null;
if (!book || !sessionKey) return null;
return (
<ReleaseModalSession
@@ -2242,6 +2372,7 @@ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
book={book}
onClose={handleClose}
isClosing={isClosing}
animateEnter={animateEnter}
{...rest}
/>
);
@@ -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<string>();
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;
+9 -1
View File
@@ -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);
}
+5
View File
@@ -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;