mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 17:21:11 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user