mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:21:11 +01:00
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. <img width="1017" height="423" alt="image" src="https://github.com/user-attachments/assets/7b84fd69-d6d3-4749-842f-e04a6e792ccc" /> <img width="682" height="418" alt="image" src="https://github.com/user-attachments/assets/82a251ee-cb4a-488f-b1ba-45fb2bb6714d" />
This commit is contained in:
+26
-24
@@ -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<QueryTargetOption[]>(
|
||||
() =>
|
||||
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(
|
||||
|
||||
@@ -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 = () => (
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0"
|
||||
@@ -205,7 +206,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
const selectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const selectPanelRef = useRef<HTMLDivElement>(null);
|
||||
const autocompletePanelRef = useRef<HTMLDivElement>(null);
|
||||
const { hoverTimeoutRef: selectorHoverTimeout, clearHoverTimeout } = useSearchBarHoverTimeout();
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
const showContentTypeSelector =
|
||||
@@ -221,7 +222,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
() => 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<SearchBarHandle, SearchBarProps>(
|
||||
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<SearchBarHandle, SearchBarProps>(
|
||||
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<SearchBarHandle, SearchBarProps>(
|
||||
setIsSelectOpen(shouldOpenSelect);
|
||||
setIsAutocompleteOpen(false);
|
||||
resetAutocomplete();
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const effectivePlaceholder = getDefaultPlaceholder(
|
||||
@@ -412,7 +412,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
setAutocompleteDraftValue(nextValue);
|
||||
setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength);
|
||||
setIsSelectOpen(false);
|
||||
setIsSelectorOpen(false);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
@@ -424,7 +423,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
textInputValue.trim().length >= autocompleteMinQueryLength
|
||||
) {
|
||||
setIsAutocompleteOpen(true);
|
||||
setIsSelectorOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -490,7 +488,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
onClick={() => {
|
||||
if (!disabled && !isDynamicLoading) {
|
||||
setIsSelectOpen((prev) => !prev);
|
||||
setIsSelectorOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}
|
||||
}}
|
||||
@@ -601,25 +598,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
}}
|
||||
>
|
||||
{showQueryTargetSelector && (
|
||||
<div
|
||||
className="relative flex shrink-0 self-stretch"
|
||||
ref={selectorRef}
|
||||
onPointerEnter={(e) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<div className="relative flex shrink-0 self-stretch" ref={selectorRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -627,11 +606,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
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<SearchBarHandle, SearchBarProps>(
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute top-1/2 right-0 h-6 w-px -translate-y-1/2"
|
||||
style={{ background: 'var(--border-muted)' }}
|
||||
/>
|
||||
|
||||
{isSelectorOpen && (
|
||||
<div
|
||||
className="animate-fade-in-down absolute top-full left-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label="Search context"
|
||||
>
|
||||
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
|
||||
{showContentTypeSelector && (
|
||||
<div
|
||||
className={`border-b ${combinedToggleAvailable ? 'pb-0' : 'pb-3'}`}
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-1 pb-2">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Content
|
||||
</span>
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<BookIcon />
|
||||
)}
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<AudiobookIcon />
|
||||
)}
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
</div>
|
||||
{combinedToggleAvailable &&
|
||||
(() => {
|
||||
const lineColor = combinedSelectionActive
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
|
||||
return (
|
||||
<Tooltip
|
||||
content="Combined search"
|
||||
position="bottom"
|
||||
triggerClassName="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCombinedModeSelect}
|
||||
className="group w-full"
|
||||
aria-label="Combined search"
|
||||
>
|
||||
{/* Bracket connector: vertical drops + horizontal bar with icon */}
|
||||
<div className="relative flex h-7 items-end">
|
||||
{/* Left vertical */}
|
||||
<div
|
||||
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${lineColor}`}
|
||||
/>
|
||||
{/* Right vertical */}
|
||||
<div
|
||||
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${lineColor}`}
|
||||
/>
|
||||
{/* Horizontal bar – left segment */}
|
||||
<div
|
||||
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${lineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
{/* Horizontal bar – right segment */}
|
||||
<div
|
||||
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
{/* Chain icon centered at bottom */}
|
||||
<div
|
||||
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
|
||||
combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{combinedSelectorLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={showContentTypeSelector ? 'pt-2' : ''}>
|
||||
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Search By
|
||||
</span>
|
||||
{!showContentTypeSelector && onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{queryTargets.map((target) => {
|
||||
const isActive = target.key === activeTarget?.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target.key}
|
||||
onClick={() => 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 && <CheckIcon />}
|
||||
<span className="block truncate">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1099,6 +818,246 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQueryTargetSelector && isSelectorOpen && (
|
||||
<div
|
||||
id={SEARCH_CONTROLS_PANEL_ID}
|
||||
className="animate-fade-in-down flex flex-wrap items-start gap-x-8 gap-y-2 px-1 pt-2"
|
||||
ref={controlsPanelRef}
|
||||
>
|
||||
{showContentTypeSelector && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Content
|
||||
</span>
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer 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)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid w-fit grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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)' }
|
||||
}
|
||||
>
|
||||
<span className="flex w-4 justify-center">
|
||||
{contentType === 'ebook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<BookIcon />
|
||||
)}
|
||||
</span>
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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)' }
|
||||
}
|
||||
>
|
||||
<span className="flex w-4 justify-center">
|
||||
{contentType === 'audiobook' || combinedSelectionActive ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<AudiobookIcon />
|
||||
)}
|
||||
</span>
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
{combinedToggleAvailable && (
|
||||
<div className="col-span-2">
|
||||
<Tooltip
|
||||
content="Combined search"
|
||||
position="bottom"
|
||||
triggerClassName="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCombinedModeSelect}
|
||||
className="group w-full cursor-pointer"
|
||||
aria-label="Combined search"
|
||||
>
|
||||
<div className="relative flex h-7 items-end">
|
||||
<div
|
||||
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${combinedLineColor}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${combinedLineColor}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${combinedLineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${combinedLineColor}`}
|
||||
style={{ width: 'calc(25% - 16px)' }}
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
|
||||
combinedSelectionActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{combinedSelectorLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryTargets.length > 1 && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between pb-1.5">
|
||||
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
|
||||
Search By
|
||||
</span>
|
||||
{!showContentTypeSelector && onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer 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)' }
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queryTargets.map((target) => {
|
||||
const isActive = target.key === activeTarget?.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target.key}
|
||||
onClick={() => 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 && <CheckIcon />}
|
||||
<span className="block truncate">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useMountEffect } from '@/hooks/useMountEffect';
|
||||
|
||||
export const useSearchBarHoverTimeout = () => {
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearHoverTimeout = useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => {
|
||||
return () => {
|
||||
clearHoverTimeout();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hoverTimeoutRef,
|
||||
clearHoverTimeout,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user