From 3937ae119bfc6b3e0da2fdc110bf8a84726283b5 Mon Sep 17 00:00:00 2001 From: Nathan H <35084611+ndawg@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:51:49 -0600 Subject: [PATCH] feat(homepage): Always show controls (#1269) I found using the main search menu frustrating. Often times, I would type in what I want, then select the category, only for it to get erased. And the menu closing over and over was distracting. So this PR makes the buttons stick around permanently and it removes the search field text changing with each button press. Obviously, this is just what I want, but I figured I'd at least put a PR up for it. --- src/frontend/src/App.tsx | 50 +- src/frontend/src/components/SearchBar.tsx | 543 ++++++++---------- .../searchBar/useSearchBarHoverTimeout.ts | 25 - 3 files changed, 277 insertions(+), 341 deletions(-) delete mode 100644 src/frontend/src/hooks/searchBar/useSearchBarHoverTimeout.ts 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); - }} - > + { @@ -627,11 +606,11 @@ export const SearchBar = forwardRef( setIsSelectOpen(false); setIsAutocompleteOpen(false); }} - className="hover-action flex items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors" + className="hover-action flex cursor-pointer items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors" style={{ color: 'var(--text)' }} aria-label={`Searching ${selectorContentTypeLabel} by ${activeTarget?.label ?? 'general'}. Click to change.`} aria-expanded={isSelectorOpen} - aria-haspopup="dialog" + aria-controls={SEARCH_CONTROLS_PANEL_ID} > {selectorIcon} {showActiveTargetLabel && ( @@ -654,270 +633,10 @@ export const SearchBar = forwardRef( /> - - - {isSelectorOpen && ( - - - {showContentTypeSelector && ( - - - - Content - - {onAdvancedToggle && ( - { - setIsSelectorOpen(false); - onAdvancedToggle(); - }} - className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${ - isAdvancedActive ? 'bg-emerald-600 text-white' : 'hover-surface' - }`} - style={ - isAdvancedActive - ? { borderColor: 'rgb(16 185 129 / 0.7)' } - : { color: 'var(--text-muted)' } - } - > - - - - Options - - )} - - - handleContentTypeSelect('ebook')} - className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ - contentType === 'ebook' || combinedSelectionActive - ? 'bg-emerald-600 text-white' - : 'hover-surface' - }`} - style={ - contentType === 'ebook' || combinedSelectionActive - ? { borderColor: 'rgb(16 185 129 / 0.7)' } - : { color: 'var(--text)', borderColor: 'var(--border-muted)' } - } - > - {contentType === 'ebook' || combinedSelectionActive ? ( - - ) : ( - - )} - Books - - handleContentTypeSelect('audiobook')} - className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ - contentType === 'audiobook' || combinedSelectionActive - ? 'bg-emerald-600 text-white' - : 'hover-surface' - }`} - style={ - contentType === 'audiobook' || combinedSelectionActive - ? { borderColor: 'rgb(16 185 129 / 0.7)' } - : { color: 'var(--text)', borderColor: 'var(--border-muted)' } - } - > - {contentType === 'audiobook' || combinedSelectionActive ? ( - - ) : ( - - )} - Audiobooks - - - {combinedToggleAvailable && - (() => { - const lineColor = combinedSelectionActive - ? 'bg-emerald-500' - : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500'; - return ( - - - {/* Bracket connector: vertical drops + horizontal bar with icon */} - - {/* Left vertical */} - - {/* Right vertical */} - - {/* Horizontal bar – left segment */} - - {/* Horizontal bar – right segment */} - - {/* Chain icon centered at bottom */} - - - {combinedSelectorLocked ? ( - - ) : ( - - )} - - - - - - ); - })()} - - )} - - - - - Search By - - {!showContentTypeSelector && onAdvancedToggle && ( - { - setIsSelectorOpen(false); - onAdvancedToggle(); - }} - className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${ - isAdvancedActive - ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` - : 'hover-surface' - }`} - style={ - isAdvancedActive - ? { - borderColor: - searchMode === 'direct' - ? 'rgb(3 105 161 / 0.7)' - : 'rgb(16 185 129 / 0.7)', - } - : { color: 'var(--text-muted)' } - } - > - - - - Options - - )} - - - {queryTargets.map((target) => { - const isActive = target.key === activeTarget?.key; - return ( - handleQueryTargetSelect(target.key)} - title={target.description || target.label} - aria-label={target.label} - className={`flex min-w-0 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ - isActive - ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` - : 'hover-surface' - }`} - style={ - isActive - ? { - borderColor: - searchMode === 'direct' - ? 'rgb(3 105 161 / 0.7)' - : 'rgb(16 185 129 / 0.7)', - } - : { color: 'var(--text)', borderColor: 'var(--border-muted)' } - } - > - {isActive && } - {target.label} - - ); - })} - - - - - )} )} @@ -1099,6 +818,246 @@ export const SearchBar = forwardRef( )} + + {showQueryTargetSelector && isSelectorOpen && ( + + {showContentTypeSelector && ( + + + + Content + + {onAdvancedToggle && ( + + + + + Options + + )} + + + handleContentTypeSelect('ebook')} + className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ + contentType === 'ebook' || combinedSelectionActive + ? 'bg-emerald-600 text-white' + : 'hover-surface' + }`} + style={ + contentType === 'ebook' || combinedSelectionActive + ? { borderColor: 'rgb(16 185 129 / 0.7)' } + : { color: 'var(--text)', borderColor: 'var(--border-muted)' } + } + > + + {contentType === 'ebook' || combinedSelectionActive ? ( + + ) : ( + + )} + + Books + + handleContentTypeSelect('audiobook')} + className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ + contentType === 'audiobook' || combinedSelectionActive + ? 'bg-emerald-600 text-white' + : 'hover-surface' + }`} + style={ + contentType === 'audiobook' || combinedSelectionActive + ? { borderColor: 'rgb(16 185 129 / 0.7)' } + : { color: 'var(--text)', borderColor: 'var(--border-muted)' } + } + > + + {contentType === 'audiobook' || combinedSelectionActive ? ( + + ) : ( + + )} + + Audiobooks + + {combinedToggleAvailable && ( + + + + + + + + + + + {combinedSelectorLocked ? ( + + ) : ( + + )} + + + + + + + )} + + + )} + + {queryTargets.length > 1 && ( + + + + Search By + + {!showContentTypeSelector && onAdvancedToggle && ( + + + + + Options + + )} + + + {queryTargets.map((target) => { + const isActive = target.key === activeTarget?.key; + return ( + handleQueryTargetSelect(target.key)} + title={target.description || target.label} + aria-label={target.label} + className={`flex min-w-0 cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ + isActive + ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` + : 'hover-surface' + }`} + style={ + isActive + ? { + borderColor: + searchMode === 'direct' + ? 'rgb(3 105 161 / 0.7)' + : 'rgb(16 185 129 / 0.7)', + } + : { color: 'var(--text)', borderColor: 'var(--border-muted)' } + } + > + {isActive && } + {target.label} + + ); + })} + + + )} + + )} > ); }, 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, - }; -};