diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index bad19116..02fc83fa 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1909,14 +1909,22 @@ function App() { effectiveSearchMode === 'universal' && (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); + // Keep the last known search fields so queryTargets doesn't collapse to + // [general] while the metadata config briefly reloads on content type switch. + const lastKnownSearchFields = useRef(activeMetadataConfig?.search_fields ?? []); + if (activeMetadataConfig?.search_fields) { + lastKnownSearchFields.current = activeMetadataConfig.search_fields; + } + const stableSearchFields = lastKnownSearchFields.current; + const queryTargets = useMemo( () => buildQueryTargets({ searchMode: effectiveSearchMode, - metadataSearchFields: activeMetadataConfig?.search_fields ?? [], + metadataSearchFields: stableSearchFields, manualSearchAllowed, }), - [effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed], + [effectiveSearchMode, stableSearchFields, manualSearchAllowed], ); const effectiveActiveQueryTarget = useMemo(() => { if (queryTargets.some((target) => target.key === activeQueryTarget)) { @@ -1952,20 +1960,18 @@ function App() { if ( !activeQueryOption || activeQueryOption.source === 'general' || - activeQueryOption.source === 'manual' + activeQueryOption.source === 'manual' || + activeQueryOption.source === 'direct-field' ) { return searchInput; } - if (activeQueryOption.source === 'direct-field') { - if (activeQueryOption.key === 'isbn') return advancedFilters.isbn; - if (activeQueryOption.key === 'author') return advancedFilters.author; - if (activeQueryOption.key === 'title') return advancedFilters.title; + if (!activeQueryOption.field) { return ''; } - if (!activeQueryOption.field) { - return ''; + if (activeQueryOption.field.type === 'TextSearchField') { + return searchInput; } if (activeQueryOption.field.type === 'CheckboxSearchField') { @@ -1975,7 +1981,7 @@ function App() { } return searchFieldValues[activeQueryOption.field.key] ?? ''; - }, [activeQueryOption, searchInput, advancedFilters, searchFieldValues]); + }, [activeQueryOption, searchInput, searchFieldValues]); const activeQueryValueLabel = useMemo(() => { if (!activeQueryOption?.field) { @@ -2023,29 +2029,25 @@ function App() { if ( !activeQueryOption || activeQueryOption.source === 'general' || - activeQueryOption.source === 'manual' + activeQueryOption.source === 'manual' || + activeQueryOption.source === 'direct-field' ) { setSearchInput(typeof value === 'string' ? value : String(value ?? '')); return; } - if (activeQueryOption.source === 'direct-field') { - const nextValue = typeof value === 'string' ? value : String(value ?? ''); - if (activeQueryOption.key === 'isbn') { - updateAdvancedFilters({ isbn: nextValue }); - } else if (activeQueryOption.key === 'author') { - updateAdvancedFilters({ author: nextValue }); - } else if (activeQueryOption.key === 'title') { - updateAdvancedFilters({ title: nextValue }); - } - return; - } - if (activeQueryOption.field) { + if (activeQueryOption.field.type === 'TextSearchField') { + setSearchInput(typeof value === 'string' ? value : String(value ?? '')); + if (label !== undefined) { + updateSearchFieldValue(activeQueryOption.field.key, value, label); + } + return; + } updateSearchFieldValue(activeQueryOption.field.key, value, label); } }, - [activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue], + [activeQueryOption, setSearchInput, updateSearchFieldValue], ); const handleSearchModeChange = useCallback( diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index 4b960faa..528d299b 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -3,7 +3,6 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'reac import { useSearchMode } from '../contexts/SearchModeContext'; import { useSearchBarAutocomplete } from '../hooks/searchBar/useSearchBarAutocomplete'; -import { useSearchBarHoverTimeout } from '../hooks/searchBar/useSearchBarHoverTimeout'; import { useDismiss } from '../hooks/useDismiss'; import type { DynamicFieldOption } from '../services/api'; import type { ContentType, MetadataSearchField, QueryTargetOption, SortOption } from '../types'; @@ -51,6 +50,8 @@ const EMPTY_SORT_OPTIONS: SortOption[] = []; const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = []; const EMPTY_QUERY_TARGETS: QueryTargetOption[] = []; +const SEARCH_CONTROLS_PANEL_ID = 'search-bar-controls-panel'; + const BookIcon = () => ( ( const selectTriggerRef = useRef(null); const selectPanelRef = useRef(null); const autocompletePanelRef = useRef(null); - const { hoverTimeoutRef: selectorHoverTimeout, clearHoverTimeout } = useSearchBarHoverTimeout(); + const controlsPanelRef = useRef(null); const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1; const showContentTypeSelector = @@ -221,7 +222,7 @@ export const SearchBar = forwardRef( () => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0], [queryTargets, activeQueryTarget], ); - const showActiveTargetLabel = queryTargets.length > 0 && activeTarget.source !== 'general'; + const showActiveTargetLabel = queryTargets.length > 0 && activeTarget?.source !== 'general'; // Manual search browses release sources directly, one media type at a time — the // combined ("both") flow doesn't apply. Present a plain, switchable Books/Audiobooks @@ -230,8 +231,11 @@ export const SearchBar = forwardRef( const combinedSelectionActive = combinedMode && !isManualTarget; const combinedSelectorLocked = combinedModeLocked && !isManualTarget; const combinedToggleAvailable = !!onCombinedModeChange && !isManualTarget; + const combinedLineColor = combinedSelectionActive + ? 'bg-emerald-500' + : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500'; - useDismiss(isSelectorOpen, [selectorRef], () => setIsSelectorOpen(false)); + useDismiss(isSelectorOpen, [selectorRef, controlsPanelRef], () => setIsSelectorOpen(false)); useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false)); useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false), @@ -333,18 +337,15 @@ export const SearchBar = forwardRef( const handleContentTypeSelect = (type: ContentType) => { onContentTypeChange?.(type); onCombinedModeChange?.(false); - setIsSelectorOpen(false); }; const handleCombinedModeSelect = () => { if (combinedMode) { - // Toggle off — revert to ebook-only onCombinedModeChange?.(false); } else { onContentTypeChange?.('ebook'); onCombinedModeChange?.(true); } - setIsSelectorOpen(false); }; const handleQueryTargetSelect = (targetKey: string) => { @@ -357,7 +358,6 @@ export const SearchBar = forwardRef( setIsSelectOpen(shouldOpenSelect); setIsAutocompleteOpen(false); resetAutocomplete(); - setIsSelectorOpen(false); }; const effectivePlaceholder = getDefaultPlaceholder( @@ -412,7 +412,6 @@ export const SearchBar = forwardRef( setAutocompleteDraftValue(nextValue); setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength); setIsSelectOpen(false); - setIsSelectorOpen(false); onChange(nextValue); return; } @@ -424,7 +423,6 @@ export const SearchBar = forwardRef( textInputValue.trim().length >= autocompleteMinQueryLength ) { setIsAutocompleteOpen(true); - setIsSelectorOpen(false); } }} onKeyDown={handleKeyDown} @@ -490,7 +488,6 @@ export const SearchBar = forwardRef( onClick={() => { if (!disabled && !isDynamicLoading) { setIsSelectOpen((prev) => !prev); - setIsSelectorOpen(false); setIsAutocompleteOpen(false); } }} @@ -601,25 +598,7 @@ export const SearchBar = forwardRef( }} > {showQueryTargetSelector && ( -
{ - if (e.pointerType !== 'mouse') return; - clearHoverTimeout(); - setIsSelectorOpen(true); - setIsSelectOpen(false); - setIsAutocompleteOpen(false); - }} - onPointerLeave={(e) => { - if (e.pointerType !== 'mouse') return; - clearHoverTimeout(); - selectorHoverTimeout.current = setTimeout(() => { - setIsSelectorOpen(false); - selectorHoverTimeout.current = null; - }, 150); - }} - > +
-
- - {isSelectorOpen && ( -
-
- {showContentTypeSelector && ( -
-
- - Content - - {onAdvancedToggle && ( - - )} -
-
- - -
- {combinedToggleAvailable && - (() => { - const lineColor = combinedSelectionActive - ? 'bg-emerald-500' - : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500'; - return ( - - - - ); - })()} -
- )} - -
-
- - Search By - - {!showContentTypeSelector && onAdvancedToggle && ( - - )} -
-
- {queryTargets.map((target) => { - const isActive = target.key === activeTarget?.key; - return ( - - ); - })} -
-
-
-
- )}
)} @@ -1099,6 +818,246 @@ export const SearchBar = forwardRef(
)}
+ + {showQueryTargetSelector && isSelectorOpen && ( +
+ {showContentTypeSelector && ( +
+
+ + Content + + {onAdvancedToggle && ( + + )} +
+
+ + + {combinedToggleAvailable && ( +
+ + + +
+ )} +
+
+ )} + + {queryTargets.length > 1 && ( +
+
+ + Search By + + {!showContentTypeSelector && onAdvancedToggle && ( + + )} +
+
+ {queryTargets.map((target) => { + const isActive = target.key === activeTarget?.key; + return ( + + ); + })} +
+
+ )} +
+ )} ); }, diff --git a/src/frontend/src/hooks/searchBar/useSearchBarHoverTimeout.ts b/src/frontend/src/hooks/searchBar/useSearchBarHoverTimeout.ts deleted file mode 100644 index 83c5119e..00000000 --- a/src/frontend/src/hooks/searchBar/useSearchBarHoverTimeout.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback, useRef } from 'react'; - -import { useMountEffect } from '@/hooks/useMountEffect'; - -export const useSearchBarHoverTimeout = () => { - const hoverTimeoutRef = useRef | null>(null); - - const clearHoverTimeout = useCallback(() => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - }, []); - - useMountEffect(() => { - return () => { - clearHoverTimeout(); - }; - }); - - return { - hoverTimeoutRef, - clearHoverTimeout, - }; -};