Feature: Hardcover list search (#694)

- Adds the functionality to search Hardcover lists, either public lists
or user's private lists
- Paste a list URL into the search box to view results
- Select a specific list from user's collection from advanced fields
dropdown
- Fixes content_type parameter in URL search query to use book/audiobook
This commit is contained in:
Alex
2026-03-03 21:53:43 +00:00
committed by GitHub
parent de18f2b9fe
commit 71900e00db
16 changed files with 816 additions and 24 deletions
+38 -4
View File
@@ -585,9 +585,13 @@ function App() {
}, [isAuthenticated, loadConfig]);
const runSearchWithPolicyRefresh = useCallback(
(query: string, fields = searchFieldValues) => {
(
query: string,
fields = searchFieldValues,
contentTypeOverride?: ContentType
) => {
void refreshRequestPolicy();
handleSearch(query, config, fields);
handleSearch(query, config, fields, contentTypeOverride);
},
[refreshRequestPolicy, handleSearch, config, searchFieldValues]
);
@@ -596,13 +600,23 @@ function App() {
useEffect(() => {
if (
wasProcessed &&
parsedParams?.hasSearchParams &&
parsedParams &&
!urlSearchExecutedRef.current &&
config
) {
urlSearchExecutedRef.current = true;
const searchMode = config.search_mode || 'direct';
const urlContentTypeOverride =
searchMode === 'universal' ? parsedParams.contentType : undefined;
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
setContentType(urlContentTypeOverride);
}
if (!parsedParams.hasSearchParams) {
return;
}
const bookLanguages = config.book_languages || [];
const defaultLanguageCodes =
config.default_language && config.default_language.length > 0
@@ -645,11 +659,12 @@ function App() {
searchMode,
});
runSearchWithPolicyRefresh(query);
runSearchWithPolicyRefresh(query, searchFieldValues, urlContentTypeOverride);
}
}, [
wasProcessed,
parsedParams,
contentType,
config,
advancedFilters,
searchFieldValues,
@@ -1315,6 +1330,23 @@ function App() {
const universalDefaultMode = getUniversalDefaultPolicyMode();
const manualSearchAllowed = searchMode === 'universal'
&& (universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
const isListBrowsing = useMemo(() => {
const dynamicFieldKeys = (config?.metadata_search_fields ?? [])
.filter((field) => field.type === 'DynamicSelectSearchField')
.map((field) => field.key);
if (dynamicFieldKeys.length === 0) {
return false;
}
return dynamicFieldKeys.some((key) => {
const value = searchFieldValues[key];
if (typeof value === 'string') {
return value.trim() !== '';
}
return value !== undefined && value !== null && value !== false;
});
}, [config?.metadata_search_fields, searchFieldValues]);
// Reset manual search if policy changes to disallow it
useEffect(() => {
@@ -1406,6 +1438,7 @@ function App() {
contentType={contentType}
onContentTypeChange={setContentType}
isManualSearch={isManualSearch}
searchDisabled={isListBrowsing}
/>
</div>
@@ -1472,6 +1505,7 @@ function App() {
onContentTypeChange={setContentType}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
searchDisabled={isListBrowsing}
/>
<ResultsSection
+3
View File
@@ -40,6 +40,7 @@ interface HeaderProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
isManualSearch?: boolean;
searchDisabled?: boolean;
}
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
@@ -71,6 +72,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
searchDisabled = false,
}, ref) => {
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
const settingsEnabled = canAccessSettings ?? isAdmin;
@@ -655,6 +657,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
contentType={contentType}
onContentTypeChange={onContentTypeChange}
isManualSearch={isManualSearch}
disabled={searchDisabled}
/>
</div>
</div>
@@ -26,6 +26,7 @@ interface SearchBarProps {
onContentTypeChange?: (type: ContentType) => void;
// Manual search mode
isManualSearch?: boolean;
disabled?: boolean;
}
export interface SearchBarHandle {
@@ -54,6 +55,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
disabled = false,
}, ref) => {
const { searchMode, isUniversalMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
@@ -110,6 +112,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (disabled) {
e.preventDefault();
return;
}
onSubmit();
(e.target as HTMLInputElement).blur();
}
@@ -124,6 +130,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const inputClasses = [
'w-full pr-40 py-3 border outline-none search-input',
showContentTypeSelector ? 'pl-3 rounded-r-full' : 'pl-4 rounded-full',
disabled ? 'opacity-60 cursor-not-allowed' : '',
inputClassName,
]
.filter(Boolean)
@@ -253,6 +260,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
type="search"
placeholder={effectivePlaceholder}
aria-label={inputAriaLabel}
disabled={disabled}
autoComplete={autoComplete}
enterKeyHint={enterKeyHint}
className={inputClasses}
@@ -27,6 +27,7 @@ interface SearchSectionProps {
// Manual search mode (universal only)
isManualSearch?: boolean;
onManualSearchToggle?: () => void;
searchDisabled?: boolean;
}
export const SearchSection = ({
@@ -50,6 +51,7 @@ export const SearchSection = ({
onContentTypeChange,
isManualSearch = false,
onManualSearchToggle,
searchDisabled = false,
}: SearchSectionProps) => {
const { searchMode } = useSearchMode();
@@ -92,6 +94,7 @@ export const SearchSection = ({
contentType={contentType}
onContentTypeChange={onContentTypeChange}
isManualSearch={isManualSearch}
disabled={searchDisabled}
/>
<AdvancedFilters
visible={showAdvanced}
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react';
import { DropdownList, DropdownListOption } from '../DropdownList';
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
interface DynamicDropdownProps {
endpoint: string;
value: string;
onChange: (value: 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;
};
export const DynamicDropdown = ({
endpoint,
value,
onChange,
placeholder = 'Select an option',
allLabel = 'All',
}: DynamicDropdownProps) => {
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
setLoadError(null);
try {
const loaded = await fetchFieldOptions(endpoint);
if (!isMounted) {
return;
}
setOptions(loaded);
} catch (error) {
if (!isMounted) {
return;
}
console.error('Failed to load dynamic dropdown options:', error);
setOptions([]);
setLoadError('Failed to load options');
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void load();
return () => {
isMounted = false;
};
}, [endpoint]);
const dropdownOptions = useMemo(() => {
if (isLoading) {
return [
{ value: '', label: allLabel },
{ value: '__loading', label: 'Loading...', disabled: true },
];
}
if (loadError) {
return [
{ value: '', label: allLabel },
{ value: '__error', label: loadError, disabled: true },
];
}
return buildOptions(options, allLabel);
}, [allLabel, 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);
};
return (
<DropdownList
options={dropdownOptions}
value={value}
onChange={handleChange}
placeholder={placeholder}
/>
);
};
@@ -1,6 +1,7 @@
import { KeyboardEvent } from 'react';
import { MetadataSearchField } from '../../types';
import { DropdownList } from '../DropdownList';
import { DynamicDropdown } from './DynamicDropdown';
interface SearchFieldRendererProps {
field: MetadataSearchField;
@@ -98,6 +99,17 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
</label>
);
case 'DynamicSelectSearchField':
return (
<DynamicDropdown
endpoint={field.options_endpoint}
value={(value as string) ?? ''}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || 'Select an option'}
allLabel="All"
/>
);
default:
return null;
}
+10 -3
View File
@@ -30,7 +30,12 @@ interface UseSearchReturn {
advancedFilters: AdvancedFilterState;
setAdvancedFilters: React.Dispatch<React.SetStateAction<AdvancedFilterState>>;
updateAdvancedFilters: (updates: Partial<AdvancedFilterState>) => void;
handleSearch: (query: string, config: AppConfig | null, fieldValues?: Record<string, string | number | boolean>) => Promise<void>;
handleSearch: (
query: string,
config: AppConfig | null,
fieldValues?: Record<string, string | number | boolean>,
contentTypeOverride?: ContentType
) => Promise<void>;
handleResetSearch: (config: AppConfig | null) => void;
handleSortChange: (value: string, config: AppConfig | null) => void;
resetSortFilter: () => void;
@@ -127,8 +132,10 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const handleSearch = useCallback(async (
query: string,
config: AppConfig | null,
fieldValues?: Record<string, string | number | boolean>
fieldValues?: Record<string, string | number | boolean>,
contentTypeOverride?: ContentType
) => {
const effectiveContentType = contentTypeOverride ?? contentType;
const searchMode = config?.search_mode || 'direct';
// In universal mode, check if we have either a query or field values
@@ -167,7 +174,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setTotalFound(0);
try {
const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, contentType);
const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, effectiveContentType);
if (result.books.length > 0) {
setBooks(result.books);
setHasMore(result.hasMore);
+1 -1
View File
@@ -40,7 +40,7 @@ export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchRetu
useEffect(() => {
if (enabled && !processedRef.current) {
const parsed = parseUrlSearchParams(searchParams);
if (parsed.hasSearchParams) {
if (parsed.hasSearchParams || parsed.contentType) {
parsedRef.current = parsed;
}
processedRef.current = true;
+31 -1
View File
@@ -12,7 +12,7 @@ import {
} from '../types';
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import { getApiBase } from '../utils/basePath';
import { getApiBase, withBasePath } from '../utils/basePath';
import {
buildAdminRequestActionUrl,
buildFulfilAdminRequestBody,
@@ -227,6 +227,13 @@ export interface MetadataSearchResult {
hasMore: boolean;
}
export interface DynamicFieldOption {
value: string;
label: string;
group?: string;
description?: string;
}
// Search metadata providers and normalize to Book format
export const searchMetadata = async (
query: string,
@@ -268,6 +275,29 @@ export const searchMetadata = async (
};
};
export const fetchFieldOptions = async (endpoint: string): Promise<DynamicFieldOption[]> => {
const normalizedEndpoint =
endpoint.startsWith('http://') || endpoint.startsWith('https://')
? endpoint
: withBasePath(endpoint);
const response = await fetchJSON<{ options?: unknown }>(normalizedEndpoint);
if (!Array.isArray(response.options)) {
return [];
}
return response.options
.filter((option): option is Record<string, unknown> => typeof option === 'object' && option !== null)
.map((option) => {
const value = typeof option.value === 'string' ? option.value : String(option.value ?? '');
const label = typeof option.label === 'string' ? option.label : value;
const group = typeof option.group === 'string' ? option.group : undefined;
const description = typeof option.description === 'string' ? option.description : undefined;
return { value, label, group, description };
})
.filter((option) => option.value !== '');
};
export const getBookInfo = async (id: string): Promise<Book> => {
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
};
@@ -0,0 +1,45 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { parseUrlSearchParams } from '../utils/parseUrlSearchParams.js';
describe('parseUrlSearchParams', () => {
it('parses standard URL search filters', () => {
const parsed = parseUrlSearchParams(
new URLSearchParams('q=dune&author=frank+herbert&lang=en&format=epub&sort=newest')
);
assert.equal(parsed.searchInput, 'dune');
assert.equal(parsed.hasSearchParams, true);
assert.deepEqual(parsed.advancedFilters, {
author: 'frank herbert',
lang: ['en'],
formats: ['epub'],
sort: 'newest',
});
assert.equal(parsed.contentType, undefined);
});
it('parses content_type for supported values', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=audiobook'));
assert.equal(parsed.searchInput, 'dune');
assert.equal(parsed.hasSearchParams, true);
assert.equal(parsed.contentType, 'audiobook');
});
it('ignores unsupported content_type values', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=podcast'));
assert.equal(parsed.searchInput, 'dune');
assert.equal(parsed.hasSearchParams, true);
assert.equal(parsed.contentType, undefined);
});
it('keeps content_type-only links from auto-triggering a blank search', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('content_type=ebook'));
assert.equal(parsed.searchInput, '');
assert.equal(parsed.hasSearchParams, false);
assert.equal(parsed.contentType, 'ebook');
});
});
+9 -2
View File
@@ -120,7 +120,8 @@ export type SearchFieldType =
| 'TextSearchField'
| 'NumberSearchField'
| 'SelectSearchField'
| 'CheckboxSearchField';
| 'CheckboxSearchField'
| 'DynamicSelectSearchField';
interface SearchFieldBase {
key: string;
@@ -151,11 +152,17 @@ export interface CheckboxSearchField extends SearchFieldBase {
default?: boolean;
}
export interface DynamicSelectSearchField extends SearchFieldBase {
type: 'DynamicSelectSearchField';
options_endpoint: string;
}
export type MetadataSearchField =
| TextSearchField
| NumberSearchField
| SelectSearchField
| CheckboxSearchField;
| CheckboxSearchField
| DynamicSelectSearchField;
// App configuration
// Content type for search (ebook vs audiobook)
+20 -3
View File
@@ -1,4 +1,4 @@
import { AdvancedFilterState } from '../types';
import { AdvancedFilterState, ContentType } from '../types';
/**
* Parsed search parameters from URL
@@ -6,24 +6,41 @@ import { AdvancedFilterState } from '../types';
export interface ParsedUrlSearch {
searchInput: string;
advancedFilters: Partial<AdvancedFilterState>;
contentType?: ContentType;
hasSearchParams: boolean;
}
const parseContentType = (value: string | null): ContentType | undefined => {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'ebook' || normalized === 'audiobook') {
return normalized;
}
return undefined;
};
/**
* Parse URL search parameters into search state.
*
* Supports both Direct Download and Universal mode parameters.
* In Universal mode, only query and sort are used (others are parsed but
* ignored by buildSearchQuery).
* In Universal mode, query/sort are used for search text, and content_type
* is used to select ebook vs audiobook.
*
* @example
* // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en
* // Universal mode: /?q=dune&sort=popularity
*/
export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch {
const parsedContentType = parseContentType(
searchParams.get('content_type') || searchParams.get('contentType')
);
const result: ParsedUrlSearch = {
searchInput: '',
advancedFilters: {},
contentType: parsedContentType,
hasSearchParams: false,
};