mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 18:11:05 +01:00
Refine UI and adjust content type settings (#705)
- Tweak manual search toggle position - Refinements to the Hardcover list dropdown behavior - Hide the content type dropdown when a content type is blocked for a user - Fixes to Hardcover author parsing to strip out initialed names - Remove `env_supported=false` for security config options.
This commit is contained in:
@@ -227,6 +227,27 @@ function App() {
|
||||
|
||||
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
||||
|
||||
// Compute which content types this user is allowed to search for.
|
||||
// If a content type's default policy mode is 'blocked', hide it from the dropdown.
|
||||
const allowedContentTypes = useMemo((): ContentType[] => {
|
||||
// If policy not loaded yet or user is admin, allow everything
|
||||
if (!requestPolicy || requestRoleIsAdmin || !requestsPolicyEnabled) {
|
||||
return ['ebook', 'audiobook'];
|
||||
}
|
||||
const types: ContentType[] = [];
|
||||
if (getDefaultMode('ebook') !== 'blocked') types.push('ebook');
|
||||
if (getDefaultMode('audiobook') !== 'blocked') types.push('audiobook');
|
||||
// If both are blocked, still show both (user can see results, just can't download)
|
||||
return types.length > 0 ? types : ['ebook', 'audiobook'];
|
||||
}, [requestPolicy, requestRoleIsAdmin, requestsPolicyEnabled, getDefaultMode]);
|
||||
|
||||
// Auto-switch content type if the current selection is blocked
|
||||
useEffect(() => {
|
||||
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
|
||||
setContentType(allowedContentTypes[0]);
|
||||
}
|
||||
}, [allowedContentTypes, contentType]);
|
||||
|
||||
const {
|
||||
isLoading: isRequestsLoading,
|
||||
cancelRequest: cancelUserRequest,
|
||||
@@ -356,6 +377,7 @@ function App() {
|
||||
handleSortChange,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
// Pagination (universal mode)
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
@@ -1385,6 +1407,13 @@ function App() {
|
||||
});
|
||||
}, [config?.metadata_search_fields, searchFieldValues]);
|
||||
|
||||
const activeListLabel = useMemo(() => {
|
||||
if (!isListBrowsing) return '';
|
||||
const field = (config?.metadata_search_fields ?? [])
|
||||
.find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]);
|
||||
return field ? (searchFieldLabels[field.key] || '') : '';
|
||||
}, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]);
|
||||
|
||||
// Reset manual search if policy changes to disallow it
|
||||
useEffect(() => {
|
||||
if (!manualSearchAllowed && isManualSearch) {
|
||||
@@ -1392,6 +1421,21 @@ function App() {
|
||||
}
|
||||
}, [manualSearchAllowed, isManualSearch]);
|
||||
|
||||
const handleManualSearchToggle = useCallback(() => {
|
||||
setIsManualSearch(prev => {
|
||||
if (!prev) {
|
||||
// Turning on: clear any dynamic select field values (e.g. list selection)
|
||||
const dynamicKeys = (config?.metadata_search_fields ?? [])
|
||||
.filter((f) => f.type === 'DynamicSelectSearchField')
|
||||
.map((f) => f.key);
|
||||
for (const key of dynamicKeys) {
|
||||
updateSearchFieldValue(key, '');
|
||||
}
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, [config?.metadata_search_fields, updateSearchFieldValue]);
|
||||
|
||||
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
|
||||
const handleSearchDispatch = useCallback(() => {
|
||||
if (isManualSearch) {
|
||||
@@ -1474,8 +1518,10 @@ function App() {
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1510,7 +1556,7 @@ function App() {
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
onSubmit={handleSearchDispatch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
/>
|
||||
|
||||
<main
|
||||
@@ -1540,9 +1586,11 @@ function App() {
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
|
||||
<ResultsSection
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AdvancedFiltersProps {
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
// Manual search mode (universal only)
|
||||
@@ -85,50 +85,72 @@ export const AdvancedFilters = ({
|
||||
// If no fields and no toggle available, don't show the section
|
||||
if (!hasProviderFields && !onManualSearchToggle) return null;
|
||||
|
||||
const manualToggle = onManualSearchToggle ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<label className="text-sm font-medium">Manual search</label>
|
||||
</div>
|
||||
<div>
|
||||
<ToggleSwitch
|
||||
checked={isManualSearch}
|
||||
onChange={() => onManualSearchToggle()}
|
||||
color="emerald"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs"><span className="opacity-60">Search release sources directly</span></p>
|
||||
</div>
|
||||
) : null;
|
||||
// When formClassName is provided (initial state), the form carries its own padding;
|
||||
// otherwise use the default positioning classes for the header-bar state.
|
||||
const wrapperClassName = formClassName
|
||||
? 'px-2'
|
||||
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
|
||||
|
||||
const universalForm = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
{manualToggle && <div className="col-span-full">{manualToggle}</div>}
|
||||
{!isManualSearch && metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value) => onSearchFieldChange?.(field.key, value)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
<div className={wrapperClassName}>
|
||||
{onManualSearchToggle && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium opacity-70">Search Options</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs opacity-60">Manual search</span>
|
||||
<ToggleSwitch
|
||||
checked={isManualSearch}
|
||||
onChange={() => onManualSearchToggle()}
|
||||
color="emerald"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
{isManualSearch && (
|
||||
<p className="text-xs opacity-50 mb-3">
|
||||
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
|
||||
</p>
|
||||
)}
|
||||
{!isManualSearch && metadataSearchFields.length > 0 && (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`${field.key}-input`} className="text-sm opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchFieldChange?.(field.key, '')}
|
||||
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value, label) => onSearchFieldChange?.(field.key, value, label)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const wrappedUniversalForm = renderWrapper ? (
|
||||
@@ -148,7 +170,7 @@ export const AdvancedFilters = ({
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
|
||||
@@ -39,8 +39,10 @@ interface HeaderProps {
|
||||
onRemoveToast?: (id: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
isManualSearch?: boolean;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
@@ -71,8 +73,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onRemoveToast,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
}, ref) => {
|
||||
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
|
||||
const settingsEnabled = canAccessSettings ?? isAdmin;
|
||||
@@ -656,8 +660,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
isLoading={isLoading}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,9 +24,11 @@ interface SearchBarProps {
|
||||
// Content type selector props
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode
|
||||
isManualSearch?: boolean;
|
||||
disabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
@@ -54,8 +56,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
enterKeyHint = 'search',
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
disabled = false,
|
||||
activeListLabel,
|
||||
}, ref) => {
|
||||
const { searchMode, isUniversalMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -65,14 +69,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
|
||||
// Content type dropdown state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes;
|
||||
|
||||
// Dynamic placeholder based on content type and manual search
|
||||
const effectivePlaceholder = isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: showContentTypeSelector
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
// Dynamic placeholder based on content type, manual search, and list browsing
|
||||
const isContentTypeAware = isUniversalMode && !!onContentTypeChange;
|
||||
const effectivePlaceholder = activeListLabel
|
||||
? `${activeListLabel} selected`
|
||||
: isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: isContentTypeAware
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
|
||||
// Close dropdown on click outside or escape
|
||||
useEffect(() => {
|
||||
@@ -162,7 +170,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
<div
|
||||
className="flex items-stretch rounded-full border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -21,13 +21,15 @@ interface SearchSectionProps {
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
@@ -49,9 +51,11 @@ export const SearchSection = ({
|
||||
onSearchFieldChange,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
@@ -93,8 +97,10 @@ export const SearchSection = ({
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
@@ -103,7 +109,7 @@ export const SearchSection = ({
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={onAdvancedFiltersChange}
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
renderWrapper={form => form}
|
||||
metadataSearchFields={metadataSearchFields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
|
||||
@@ -2,26 +2,30 @@ import { useEffect, useState } from 'react';
|
||||
import { getAdminUsers } from '../../../services/api';
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
const ADMIN_CHECK_KEYS = new Set(['builtin_admin_requirement', 'oidc_admin_requirement']);
|
||||
|
||||
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
|
||||
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
|
||||
const needsAdminCheck = ADMIN_CHECK_KEYS.has(field.key);
|
||||
const [visible, setVisible] = useState(!needsAdminCheck);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAdminCheck) return;
|
||||
let cancelled = false;
|
||||
getAdminUsers()
|
||||
.then((users) => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
|
||||
setVisible(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(true);
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [needsAdminCheck]);
|
||||
|
||||
if (!needsAdmin) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -5,48 +5,25 @@ import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
|
||||
interface DynamicDropdownProps {
|
||||
endpoint: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string, label?: string) => void;
|
||||
placeholder?: string;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
const GROUP_HEADER_PREFIX = '__group__';
|
||||
|
||||
const buildOptions = (
|
||||
options: DynamicFieldOption[],
|
||||
allLabel: string
|
||||
): DropdownListOption[] => {
|
||||
const built: DropdownListOption[] = [{ value: '', label: allLabel }];
|
||||
let previousGroup: string | null = null;
|
||||
|
||||
options.forEach((option, index) => {
|
||||
if (option.group && option.group !== previousGroup) {
|
||||
previousGroup = option.group;
|
||||
built.push({
|
||||
value: `${GROUP_HEADER_PREFIX}${option.group}:${index}`,
|
||||
label: option.group,
|
||||
disabled: true,
|
||||
});
|
||||
} else if (!option.group) {
|
||||
previousGroup = null;
|
||||
}
|
||||
|
||||
built.push({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
});
|
||||
});
|
||||
|
||||
return built;
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}));
|
||||
};
|
||||
|
||||
export const DynamicDropdown = ({
|
||||
endpoint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select an option',
|
||||
allLabel = 'All',
|
||||
placeholder = 'Select...',
|
||||
}: DynamicDropdownProps) => {
|
||||
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -87,28 +64,20 @@ export const DynamicDropdown = ({
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return [
|
||||
{ value: '', label: allLabel },
|
||||
{ value: '__loading', label: 'Loading...', disabled: true },
|
||||
];
|
||||
return [{ value: '__loading', label: 'Loading...', disabled: true }];
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return [
|
||||
{ value: '', label: allLabel },
|
||||
{ value: '__error', label: loadError, disabled: true },
|
||||
];
|
||||
return [{ value: '__error', label: loadError, disabled: true }];
|
||||
}
|
||||
|
||||
return buildOptions(options, allLabel);
|
||||
}, [allLabel, isLoading, loadError, options]);
|
||||
return buildOptions(options);
|
||||
}, [isLoading, loadError, options]);
|
||||
|
||||
const handleChange = (nextValue: string[] | string) => {
|
||||
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
|
||||
if (normalized.startsWith(GROUP_HEADER_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
onChange(normalized);
|
||||
const match = options.find((opt) => opt.value === normalized);
|
||||
onChange(normalized, match?.label);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DynamicDropdown } from './DynamicDropdown';
|
||||
interface SearchFieldRendererProps {
|
||||
field: MetadataSearchField;
|
||||
value: string | number | boolean;
|
||||
onChange: (value: string | number | boolean) => void;
|
||||
onChange: (value: string | number | boolean, label?: string) => void;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
@@ -104,9 +104,8 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
|
||||
<DynamicDropdown
|
||||
endpoint={field.options_endpoint}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder || 'Select an option'}
|
||||
allLabel="All"
|
||||
onChange={(v, label) => onChange(v, label)}
|
||||
placeholder={field.placeholder || 'Select...'}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
|
||||
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
|
||||
@@ -41,7 +41,8 @@ interface UseSearchReturn {
|
||||
resetSortFilter: () => void;
|
||||
// Universal mode search field values
|
||||
searchFieldValues: SearchFieldValues;
|
||||
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
|
||||
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
searchFieldLabels: Record<string, string>;
|
||||
// Pagination (universal mode only)
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
@@ -70,6 +71,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
|
||||
// Universal mode: provider-specific search field values
|
||||
const [searchFieldValues, setSearchFieldValues] = useState<SearchFieldValues>({});
|
||||
const [searchFieldLabels, setSearchFieldLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// Pagination state (universal mode only)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -83,31 +85,18 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
sort: string;
|
||||
fieldValues: SearchFieldValues;
|
||||
} | null>(null);
|
||||
const previousContentTypeRef = useRef<ContentType>(contentType);
|
||||
|
||||
// Switching media type starts a new search session.
|
||||
useEffect(() => {
|
||||
if (previousContentTypeRef.current === contentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousContentTypeRef.current = contentType;
|
||||
setBooks([]);
|
||||
setLastSearchQuery('');
|
||||
setSearchFieldValues({});
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setCurrentPage(1);
|
||||
lastSearchParamsRef.current = null;
|
||||
onSearchReset?.();
|
||||
}, [contentType, onSearchReset]);
|
||||
|
||||
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
|
||||
setAdvancedFilters(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
|
||||
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => {
|
||||
setSearchFieldValues(prev => ({ ...prev, [key]: value }));
|
||||
if (label !== undefined) {
|
||||
setSearchFieldLabels(prev => ({ ...prev, [key]: label }));
|
||||
} else if (!value) {
|
||||
setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetSortFilter = useCallback(() => {
|
||||
@@ -319,6 +308,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
// Universal mode search field values
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
// Pagination (universal mode only)
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
|
||||
Reference in New Issue
Block a user