Expanded Hardcover list features (#719)

- Adds full interaction with Hardcover lists, including adding and
removing from lists + want to read status
- List selection exposed in search results, details modal and release
modal
- Added automatic list dropdown when selecting "list" search
- Added auto-removal of books from a list when downloading from that
specific list page
- Changed search selector to hover-activated
This commit is contained in:
Alex
2026-03-07 15:33:46 +00:00
committed by GitHub
parent 80aa289a64
commit 9d08bb3ef1
23 changed files with 1824 additions and 214 deletions
+56 -2
View File
@@ -29,6 +29,7 @@ import {
createRequest,
isApiResponseError,
updateSelfUser,
setBookTargetState,
type DownloadReleasePayload,
} from './services/api';
import { useToast } from './hooks/useToast';
@@ -76,6 +77,8 @@ import {
toContentType,
} from './utils/requestPayload';
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
import { useSocket } from './contexts/SocketContext';
@@ -384,6 +387,7 @@ function App() {
isLoadingMore,
loadMore,
totalFound,
resultsSourceUrl,
} = useSearch({
showToast,
setIsAuthenticated,
@@ -392,6 +396,19 @@ function App() {
contentType,
});
// When a book is removed from the Hardcover list currently being browsed, remove it from results
const searchFieldValuesRef = useRef(searchFieldValues);
searchFieldValuesRef.current = searchFieldValues;
useEffect(() => {
return onBookTargetChange((event) => {
if (event.selected) return;
const activeListValue = searchFieldValuesRef.current.hardcover_list;
if (!activeListValue || String(activeListValue) !== event.target) return;
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
});
}, [setBooks]);
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(null);
@@ -1072,6 +1089,30 @@ function App() {
[]
);
// When downloading a book while browsing a Hardcover list, automatically
// remove it from that list (fire-and-forget).
const searchFieldLabelsRef = useRef(searchFieldLabels);
searchFieldLabelsRef.current = searchFieldLabels;
const removeBookFromActiveList = useCallback((book: Book) => {
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
if (result.changed) {
emitBookTargetChange({
provider: book.provider!,
bookId: book.provider_id!,
target,
selected: false,
});
const listName = searchFieldLabelsRef.current['hardcover_list'];
showToast(`Removed from ${listName || 'list'}`, 'info');
}
}).catch(() => {});
}, [showToast]);
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
@@ -1079,6 +1120,7 @@ function App() {
try {
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
console.error('Download failed:', error);
if (isPolicyGuardError(error)) {
@@ -1103,7 +1145,7 @@ function App() {
throw error;
}
},
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast]
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast]
);
const executeReleaseDownload = useCallback(
@@ -1120,6 +1162,7 @@ function App() {
onBehalfOfUserId
);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
console.error('Release download failed:', error);
if (isPolicyGuardError(error)) {
@@ -1168,7 +1211,7 @@ function App() {
throw error;
}
},
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast, trackRelease]
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease]
);
const handleConfirmOnBehalfDownload = useCallback(async (): Promise<boolean> => {
@@ -1657,6 +1700,12 @@ function App() {
&& activeQueryValue !== ''
&& activeQueryValue !== false,
);
const activeQueryUsesListBrowse = Boolean(
activeQueryOption?.source === 'provider-field'
&& activeQueryOption.field?.type === 'DynamicSelectSearchField'
&& activeQueryValue !== ''
&& activeQueryValue !== false,
);
const effectiveMetadataSort = getEffectiveMetadataSort({
currentSort: advancedFilters.sort,
defaultSort: resolvedMetadataDefaultSort,
@@ -2086,6 +2135,7 @@ function App() {
getButtonState={getDirectActionButtonState}
getUniversalButtonState={getUniversalActionButtonState}
sortValue={visibleResultsSort}
showSortControl={!activeQueryUsesSeriesBrowse && !activeQueryUsesListBrowse}
onSortChange={(value) => {
const request = buildCurrentSearchRequest(value);
const shouldPersistAppliedSort = !(
@@ -2109,6 +2159,8 @@ function App() {
isLoadingMore={isLoadingMore}
onLoadMore={() => loadMore(config, effectiveSearchMode)}
totalFound={totalFound}
onShowToast={showToast}
resultsSourceUrl={resultsSourceUrl}
/>
{selectedBook && (
@@ -2116,6 +2168,7 @@ function App() {
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
onShowToast={showToast}
onFindDownloads={(book) => {
setSelectedBook(null);
void handleGetReleases(book);
@@ -2152,6 +2205,7 @@ function App() {
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
onShowToast={showToast}
/>
)}
@@ -0,0 +1,248 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DropdownList, type DropdownListOption } from './DropdownList';
import {
setBookTargetState,
type BookTargetOption,
} from '../services/api';
import { loadBookTargets } from '../utils/bookTargetLoader';
import { emitBookTargetChange, onBookTargetChange } from '../utils/bookTargetEvents';
interface BookTargetDropdownProps {
provider: string;
bookId: string;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
widthClassName?: string;
variant?: 'default' | 'pill' | 'icon';
align?: 'left' | 'right' | 'auto';
className?: string;
onOpenChange?: (isOpen: boolean) => void;
}
const stripCountSuffix = (label: string): string => {
return label.replace(/\s+\(\d+\)\s*$/, '');
};
const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className={`${className} flex-shrink-0`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
);
const renderSummary = (selectedOptions: DropdownListOption[]) => {
const count = selectedOptions.length;
return (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<BookmarkIcon />
<span>Hardcover Lists{count > 0 ? ` (${count})` : ''}</span>
</span>
);
};
const updateOptionChecked = (
prev: BookTargetOption[],
target: string,
checked: boolean,
): BookTargetOption[] =>
prev.map((option) =>
option.value === target ? { ...option, checked } : option,
);
export const BookTargetDropdown = ({
provider,
bookId,
onShowToast,
widthClassName = 'w-full sm:w-56',
variant = 'default',
align = 'auto',
className,
onOpenChange,
}: BookTargetDropdownProps) => {
const [options, setOptions] = useState<BookTargetOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [pendingTargets, setPendingTargets] = useState<Set<string>>(new Set());
useEffect(() => {
let isMounted = true;
const run = async () => {
try {
const loaded = await loadBookTargets(provider, bookId);
if (!isMounted) return;
setOptions(loaded);
setLoadError(null);
} catch (error) {
if (!isMounted) return;
const message = error instanceof Error ? error.message : 'Failed to load Hardcover lists';
setOptions([]);
setLoadError(message);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
setLoadError(null);
setPendingTargets(new Set());
setIsLoading(true);
void run();
return () => {
isMounted = false;
};
}, [provider, bookId]);
// Sync from changes made by other BookTargetDropdown instances for the same book
useEffect(() => {
return onBookTargetChange((event) => {
if (event.provider !== provider || event.bookId !== bookId) return;
setOptions((prev) => updateOptionChecked(prev, event.target, event.selected));
});
}, [provider, bookId]);
const selectedValues = useMemo(
() => options.filter((option) => option.checked).map((option) => option.value),
[options],
);
const dropdownOptions = useMemo<DropdownListOption[]>(() => {
if (isLoading) {
return [{ value: '__loading', label: 'Loading…', disabled: true }];
}
if (loadError) {
return [{ value: '__error', label: loadError, disabled: true }];
}
if (options.length === 0) {
return [{ value: '__empty', label: 'No writable Hardcover targets', disabled: true }];
}
return options.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
disabled: !option.writable || pendingTargets.has(option.value),
}));
}, [isLoading, loadError, options, pendingTargets]);
const handleChange = useCallback((nextValue: string[] | string) => {
if (!Array.isArray(nextValue)) {
return;
}
const nextSelected = new Set(nextValue);
const currentSelected = new Set(selectedValues);
const toggledTarget =
nextValue.find((value) => !currentSelected.has(value))
?? selectedValues.find((value) => !nextSelected.has(value));
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
return;
}
const selected = nextSelected.has(toggledTarget);
const toggledOption = options.find((option) => option.value === toggledTarget);
if (!toggledOption) {
return;
}
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
void (async () => {
try {
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target: toggledTarget,
selected: result.selected,
});
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
'success',
);
}
} catch (error) {
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
const message = error instanceof Error ? error.message : 'Failed to update Hardcover list';
onShowToast?.(message, 'error');
} finally {
setPendingTargets((prev) => {
const nextPending = new Set(prev);
nextPending.delete(toggledTarget);
return nextPending;
});
}
})();
}, [bookId, onShowToast, options, pendingTargets, provider, selectedValues]);
const customTrigger = variant === 'pill'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
</button>
);
}
: variant === 'icon'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
className={`flex items-center justify-center rounded-full transition-all duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
aria-label="Hardcover Lists"
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
>
<BookmarkIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${count > 0 ? 'fill-current' : ''}`} />
</button>
);
}
: undefined;
return (
<DropdownList
options={dropdownOptions}
value={selectedValues}
onChange={handleChange}
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
panelClassName={variant !== 'default' ? 'w-56' : undefined}
align={align}
multiple
showCheckboxes
keepOpenOnSelect
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
renderTrigger={customTrigger}
onOpenChange={onOpenChange}
/>
);
};
+42 -19
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
interface DetailsModalProps {
book: Book | null;
@@ -10,9 +12,18 @@ interface DetailsModalProps {
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => {
export const DetailsModal = ({
book,
onClose,
onDownload,
onFindDownloads,
onSearchSeries,
buttonState,
onShowToast,
}: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@@ -56,6 +67,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
}
}, [book]);
const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book));
if (!book && !isClosing) return null;
if (!book) return null;
@@ -319,14 +332,14 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Source link - shown for both Universal and Direct Download modes */}
{book.source_url && (
<a
href={book.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View on {isMetadata ? providerDisplay : "Source"}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -339,22 +352,32 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
</svg>
</a>
)}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
: buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
}`}
>
{isMetadata ? metadataActionText : buttonState.text}
</button>
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
{hasBookTargets && book.provider_id && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id}
onShowToast={onShowToast}
widthClassName="w-full sm:w-56"
/>
)}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-emerald-600 hover:bg-emerald-700'
: buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-sky-700 hover:bg-sky-800'
}`}
>
{isMetadata ? metadataActionText : buttonState.text}
</button>
</div>
</div>
</footer>
</div>
+33 -6
View File
@@ -6,7 +6,7 @@ function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null
while (current) {
const style = getComputedStyle(current);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') {
return current;
}
current = current.parentElement;
@@ -41,7 +41,7 @@ interface DropdownProps {
label?: string;
summary?: ReactNode;
children: (helpers: { close: () => void }) => ReactNode;
align?: 'left' | 'right';
align?: 'left' | 'right' | 'auto';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
@@ -50,6 +50,7 @@ interface DropdownProps {
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
noScrollLimit?: boolean;
triggerChrome?: 'default' | 'minimal';
onOpenChange?: (isOpen: boolean) => void;
}
export const Dropdown = ({
@@ -64,18 +65,27 @@ export const Dropdown = ({
renderTrigger,
noScrollLimit = false,
triggerChrome = 'default',
onOpenChange,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(align === 'right' ? 'right' : 'left');
const toggleOpen = () => {
if (disabled) return;
setIsOpen(prev => !prev);
setIsOpen(prev => {
const next = !prev;
onOpenChange?.(next);
return next;
});
};
const close = () => setIsOpen(false);
const close = () => {
setIsOpen(false);
onOpenChange?.(false);
};
useEffect(() => {
if (!isOpen) return;
@@ -124,7 +134,24 @@ export const Dropdown = ({
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
}, []);
// Auto horizontal alignment: check if panel overflows viewport right/left
if (align === 'auto') {
const panelWidth = panelRef.current.offsetWidth || panelRef.current.scrollWidth;
const overflowsRight = rect.left + panelWidth > window.innerWidth - 8;
const overflowsLeft = rect.right - panelWidth < 8;
if (overflowsRight && !overflowsLeft) {
setResolvedAlign('right');
} else if (overflowsLeft && !overflowsRight) {
setResolvedAlign('left');
} else {
setResolvedAlign('left');
}
} else {
setResolvedAlign(align === 'right' ? 'right' : 'left');
}
}, [align]);
useLayoutEffect(() => {
if (!isOpen) return;
@@ -192,7 +219,7 @@ export const Dropdown = ({
{isOpen && (
<div
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
+10 -1
View File
@@ -17,12 +17,15 @@ interface DropdownListProps {
showCheckboxes?: boolean;
value: string[] | string | null | undefined;
onChange: (value: string[] | string) => void;
align?: 'left' | 'right';
align?: 'left' | 'right' | 'auto';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
keepOpenOnSelect?: boolean;
triggerChrome?: 'default' | 'minimal';
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
onOpenChange?: (isOpen: boolean) => void;
}
export const DropdownList = ({
@@ -36,9 +39,12 @@ export const DropdownList = ({
align,
widthClassName,
buttonClassName,
panelClassName,
summaryFormatter,
keepOpenOnSelect,
triggerChrome = 'default',
renderTrigger,
onOpenChange,
}: DropdownListProps) => {
const selectedValues = normalizeValue(value, multiple);
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
@@ -104,7 +110,10 @@ export const DropdownList = ({
align={align}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
panelClassName={panelClassName}
triggerChrome={triggerChrome}
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
@@ -20,6 +20,8 @@ import { useSocket } from '../contexts/SocketContext';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { BookDownloadButton } from './BookDownloadButton';
import { BookTargetDropdown } from './BookTargetDropdown';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { ReleaseCell } from './ReleaseCell';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
@@ -95,6 +97,7 @@ interface ReleaseModalProps {
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
defaultShowManualQuery?: boolean;
isRequestMode?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
@@ -529,6 +532,7 @@ export const ReleaseModal = ({
onSearchSeries,
defaultShowManualQuery = false,
isRequestMode = false,
onShowToast,
}: ReleaseModalProps) => {
// Use audiobook formats when in audiobook mode
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
@@ -1491,6 +1495,14 @@ export const ReleaseModal = ({
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="pill"
/>
)}
</div>
</div>
</div>
+31 -4
View File
@@ -25,11 +25,14 @@ interface ResultsSectionProps {
sortValue: string;
onSortChange: (value: string) => void;
metadataSortOptions?: SortOption[];
showSortControl?: boolean;
// Pagination (universal mode)
hasMore?: boolean;
isLoadingMore?: boolean;
onLoadMore?: () => void;
totalFound?: number;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
resultsSourceUrl?: string;
}
export const ResultsSection = ({
@@ -43,10 +46,13 @@ export const ResultsSection = ({
sortValue,
onSortChange,
metadataSortOptions,
showSortControl = true,
hasMore,
isLoadingMore,
onLoadMore,
totalFound,
onShowToast,
resultsSourceUrl,
}: ResultsSectionProps) => {
const { searchMode } = useSearchMode();
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
@@ -86,10 +92,29 @@ export const ResultsSection = ({
return (
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
{showSortControl ? (
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
) : resultsSourceUrl ? (
<a
href={resultsSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 animate-pop-up"
>
View list on Hardcover
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
) : null}
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-auto">
{isDesktop && (
<button
onClick={() => setViewMode('card')}
@@ -175,7 +200,7 @@ export const ResultsSection = ({
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} />
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} onShowToast={onShowToast} />
) : (
<div
id="results-grid"
@@ -199,6 +224,7 @@ export const ResultsSection = ({
buttonState={buttonState}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
onShowToast={onShowToast}
/>
) : (
<CompactView
@@ -211,6 +237,7 @@ export const ResultsSection = ({
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
onShowToast={onShowToast}
/>
);
})}
+38 -4
View File
@@ -187,6 +187,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const selectTriggerRef = useRef<HTMLButtonElement>(null);
const selectPanelRef = useRef<HTMLDivElement>(null);
const autocompletePanelRef = useRef<HTMLDivElement>(null);
const selectorHoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const deferredTextInputValue = useDeferredValue(textInputValue);
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
@@ -210,9 +211,22 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false));
// Close select dropdown when active field changes
// Clean up hover timeout on unmount
useEffect(() => {
setIsSelectOpen(false);
return () => {
if (selectorHoverTimeout.current) clearTimeout(selectorHoverTimeout.current);
};
}, []);
// Auto-open select dropdown only when transitioning into a select field
const prevFieldKeyRef = useRef<string | undefined>(undefined);
useEffect(() => {
const fieldKey = activeQueryField?.key;
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
prevFieldKeyRef.current = fieldKey;
setIsSelectOpen(fieldChanged && isSelect);
setIsAutocompleteOpen(false);
setAutocompleteOptions([]);
}, [activeQueryField?.key, activeQueryField?.type]);
@@ -434,6 +448,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onFocus={() => {
if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) {
setIsAutocompleteOpen(true);
setIsSelectorOpen(false);
}
}}
onKeyDown={handleKeyDown}
@@ -496,6 +511,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
if (!disabled && !isDynamicLoading) {
setIsSelectOpen((prev) => !prev);
setIsSelectorOpen(false);
setIsAutocompleteOpen(false);
}
}}
disabled={disabled}
@@ -559,10 +575,28 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
}}
>
{showQueryTargetSelector && (
<div className="relative flex-shrink-0 flex self-stretch" ref={selectorRef}>
<div
className="relative flex-shrink-0 flex self-stretch"
ref={selectorRef}
onMouseEnter={() => {
if (selectorHoverTimeout.current) {
clearTimeout(selectorHoverTimeout.current);
selectorHoverTimeout.current = null;
}
setIsSelectorOpen(true);
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onMouseLeave={() => {
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
}, 150);
}}
>
<button
type="button"
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); }}
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); setIsAutocompleteOpen(false); }}
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
style={{ color: 'var(--text)' }}
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-[100] space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
@@ -16,15 +18,17 @@ interface CardViewProps {
buttonState: ButtonStateInfo;
animationDelay?: number;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false }: CardViewProps) => {
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false, onShowToast }: CardViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -46,83 +50,100 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform"
className="book-card flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }}
/>
</div>
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 max-sm:hidden transition-all duration-300"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
</button>
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
</div>
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
@@ -17,15 +19,17 @@ interface CompactViewProps {
showDetailsButton?: boolean;
animationDelay?: number;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false }: CompactViewProps) => {
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false, onShowToast }: CompactViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -47,11 +51,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform"
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
@@ -59,69 +64,85 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered ? 0.02 : 0 }} />
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }} />
</div>
{!showDetailsButton && (
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
<div
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 transition-all duration-300"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
</button>
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
)}
</div>
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
@@ -13,6 +15,7 @@ interface ListViewProps {
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
@@ -48,10 +51,11 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
);
};
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false }: ListViewProps) => {
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false, onShowToast }: ListViewProps) => {
const { searchMode } = useSearchMode();
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
const [openDropdownBookId, setOpenDropdownBookId] = useState<string | null>(null);
if (books.length === 0) {
return null;
@@ -77,7 +81,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<article
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
className="w-full rounded-lg sm:rounded-2xl"
style={{
background: 'var(--bg-soft)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
@@ -100,8 +104,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform"
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform relative"
style={{
zIndex: openDropdownBookId === book.id ? 30 : undefined,
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
@@ -218,6 +223,15 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
onOpenChange={(isOpen) => setOpenDropdownBookId(isOpen ? book.id : null)}
/>
)}
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
onClick={() => handleDetails(book.id)}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { DropdownList, DropdownListOption } from '../DropdownList';
import { DropdownList, type DropdownListOption } from '../DropdownList';
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
const optionsCache = new Map<string, DynamicFieldOption[]>();
@@ -92,20 +92,22 @@ export const DynamicDropdown = ({
return [{ value: '__error', label: loadError, disabled: true }];
}
if (options.length === 0) {
return [{ value: '__empty', label: 'No options available', disabled: true }];
}
return buildOptions(options);
}, [isLoading, loadError, options]);
const handleChange = (nextValue: string[] | string) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
const match = options.find((opt) => opt.value === normalized);
onChange(normalized, match?.label);
};
return (
<DropdownList
options={dropdownOptions}
value={value}
onChange={handleChange}
onChange={(nextValue) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
const match = options.find((option) => option.value === normalized);
onChange(normalized, match?.label);
}}
placeholder={placeholder}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
+9 -1
View File
@@ -20,7 +20,7 @@ type SearchFieldValues = Record<string, string | number | boolean>;
interface UseSearchReturn {
books: Book[];
setBooks: (books: Book[]) => void;
setBooks: React.Dispatch<React.SetStateAction<Book[]>>;
isSearching: boolean;
lastSearchQuery: string;
searchInput: string;
@@ -49,6 +49,8 @@ interface UseSearchReturn {
isLoadingMore: boolean;
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
totalFound: number;
// Source URL for the current result set (e.g. Hardcover list page)
resultsSourceUrl: string | undefined;
}
export function useSearch(options: UseSearchOptions): UseSearchReturn {
@@ -79,6 +81,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [totalFound, setTotalFound] = useState(0);
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
// Store last search params for loadMore
const lastSearchParamsRef = useRef<{
@@ -163,6 +166,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
setResultsSourceUrl(undefined);
lastSearchParamsRef.current = null;
return;
}
@@ -188,6 +192,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setBooks(result.books);
setHasMore(result.hasMore);
setTotalFound(result.totalFound);
setResultsSourceUrl(result.sourceUrl);
// Store params for loadMore
lastSearchParamsRef.current = {
query: searchQuery,
@@ -200,6 +205,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setBooks([]);
setHasMore(false);
setTotalFound(0);
setResultsSourceUrl(undefined);
showToast('No results found', 'error');
}
} catch (error) {
@@ -269,6 +275,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setCurrentPage(1);
setHasMore(false);
setTotalFound(0);
setResultsSourceUrl(undefined);
lastSearchParamsRef.current = null;
}, [onSearchReset]);
@@ -332,5 +339,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
isLoadingMore,
loadMore,
totalFound,
resultsSourceUrl,
};
}
+103 -9
View File
@@ -224,6 +224,7 @@ interface MetadataSearchResponse {
page?: number;
total_found?: number;
has_more?: boolean;
source_url?: string;
}
// Metadata search result with pagination info
@@ -232,6 +233,7 @@ export interface MetadataSearchResult {
page: number;
totalFound: number;
hasMore: boolean;
sourceUrl?: string;
}
export interface DynamicFieldOption {
@@ -241,6 +243,20 @@ export interface DynamicFieldOption {
description?: string;
}
export interface BookTargetOption {
value: string;
label: string;
group?: string;
description?: string;
checked: boolean;
writable: boolean;
}
export interface BookTargetStateResult {
changed: boolean;
selected: boolean;
}
// Search metadata providers and normalize to Book format
export const searchMetadata = async (
query: string,
@@ -283,6 +299,7 @@ export const searchMetadata = async (
page: response.page || page,
totalFound: response.total_found || 0,
hasMore: response.has_more || false,
sourceUrl: response.source_url,
};
};
@@ -328,18 +345,95 @@ export const fetchFieldOptions = async (
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 };
})
return parseOptionList(response.options).map(({ value, label, group, description }) => ({
value,
label,
group,
description,
}));
};
const parseBaseOption = (
option: Record<string, unknown>,
): { value: string; label: string; group?: string; description?: string } => {
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 };
};
const parseOptionList = (raw: unknown): ReturnType<typeof parseBaseOption>[] => {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map(parseBaseOption)
.filter((option) => option.value !== '');
};
const parseBookTargetOptions = (raw: unknown): BookTargetOption[] => {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
...parseBaseOption(item),
checked: item.checked === true,
writable: item.writable !== false,
}))
.filter((option) => option.value !== '');
};
export const fetchBookTargetOptions = async (
provider: string,
bookId: string,
): Promise<BookTargetOption[]> => {
const response = await fetchJSON<{ options?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`
);
return parseBookTargetOptions(response.options);
};
export const fetchBookTargetOptionsBatch = async (
provider: string,
bookIds: string[],
): Promise<Map<string, BookTargetOption[]>> => {
const response = await fetchJSON<{ results?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/targets/batch`,
{
method: 'POST',
body: JSON.stringify({ book_ids: bookIds }),
}
);
const results = new Map<string, BookTargetOption[]>();
if (typeof response.results === 'object' && response.results !== null) {
for (const [bookId, options] of Object.entries(response.results as Record<string, unknown>)) {
results.set(bookId, parseBookTargetOptions(options));
}
}
return results;
};
export const setBookTargetState = async (
provider: string,
bookId: string,
target: string,
selected: boolean,
): Promise<BookTargetStateResult> => {
const response = await fetchJSON<{ changed?: unknown; selected?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`,
{
method: 'PUT',
body: JSON.stringify({ target, selected }),
}
);
return {
changed: response.changed === true,
selected: response.selected === true,
};
};
export const getSourceRecordInfo = async (source: string, id: string): Promise<Book> => {
const response = await fetchJSON<SourceRecordData>(
`${API_BASE}/release-sources/${encodeURIComponent(source)}/records/${encodeURIComponent(id)}`
@@ -0,0 +1,19 @@
type BookTargetChangeEvent = {
provider: string;
bookId: string;
target: string;
selected: boolean;
};
type Listener = (event: BookTargetChangeEvent) => void;
const listeners = new Set<Listener>();
export const onBookTargetChange = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
export const emitBookTargetChange = (event: BookTargetChangeEvent): void => {
listeners.forEach((listener) => listener(event));
};
+145
View File
@@ -0,0 +1,145 @@
import { fetchBookTargetOptionsBatch, type BookTargetOption } from '../services/api';
import { onBookTargetChange } from './bookTargetEvents';
import type { Book } from '../types';
// Providers that support the book targets feature.
// Centralised here so the check isn't hardcoded across every view component.
const PROVIDERS_WITH_TARGETS = new Set(['hardcover']);
/** Returns true if the book's provider supports the book-target dropdown. */
export const bookSupportsTargets = (book: Book): boolean =>
Boolean(book.provider && book.provider_id && PROVIDERS_WITH_TARGETS.has(book.provider));
type PendingRequest = {
bookId: string;
resolve: (options: BookTargetOption[]) => void;
reject: (error: unknown) => void;
};
const BATCH_SIZE = 50;
const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingByProvider = new Map<string, PendingRequest[]>();
let flushScheduled = false;
// Client-side cache to avoid re-fetching on view mode switches and re-renders
const cache = new Map<string, { options: BookTargetOption[]; expiresAt: number }>();
const cacheKey = (provider: string, bookId: string) => `${provider}:${bookId}`;
const getCached = (provider: string, bookId: string): BookTargetOption[] | undefined => {
const entry = cache.get(cacheKey(provider, bookId));
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
cache.delete(cacheKey(provider, bookId));
return undefined;
}
return entry.options;
};
const setCache = (provider: string, bookId: string, options: BookTargetOption[]) => {
cache.set(cacheKey(provider, bookId), { options, expiresAt: Date.now() + CACHE_TTL_MS });
};
export const invalidateCache = (provider: string, bookId: string) => {
cache.delete(cacheKey(provider, bookId));
};
// Invalidate cache when a target changes
onBookTargetChange((event) => {
invalidateCache(event.provider, event.bookId);
});
const fetchChunked = async (
provider: string,
bookIds: string[],
): Promise<Map<string, BookTargetOption[]>> => {
if (bookIds.length <= BATCH_SIZE) {
return fetchBookTargetOptionsBatch(provider, bookIds);
}
const merged = new Map<string, BookTargetOption[]>();
const chunks: string[][] = [];
for (let i = 0; i < bookIds.length; i += BATCH_SIZE) {
chunks.push(bookIds.slice(i, i + BATCH_SIZE));
}
const results = await Promise.all(
chunks.map((chunk) => fetchBookTargetOptionsBatch(provider, chunk)),
);
for (const chunkResult of results) {
for (const [bookId, options] of chunkResult) {
merged.set(bookId, options);
}
}
return merged;
};
const flush = async () => {
flushScheduled = false;
const snapshot = new Map(pendingByProvider);
pendingByProvider.clear();
await Promise.all(
[...snapshot].map(async ([provider, requests]) => {
// Deduplicate and separate cached vs uncached
const uncachedIds = new Set<string>();
for (const req of requests) {
if (!getCached(provider, req.bookId)) {
uncachedIds.add(req.bookId);
}
}
try {
let fetched = new Map<string, BookTargetOption[]>();
if (uncachedIds.size > 0) {
fetched = await fetchChunked(provider, [...uncachedIds]);
for (const [bookId, options] of fetched) {
setCache(provider, bookId, options);
}
}
for (const req of requests) {
const options = getCached(provider, req.bookId) ?? fetched.get(req.bookId) ?? [];
req.resolve(options);
}
} catch (error) {
for (const req of requests) {
req.reject(error);
}
}
}),
);
};
/**
* Load book target options using automatic batching.
*
* Multiple calls within the same microtask are coalesced into a single
* batch API request per provider. Results are cached client-side to avoid
* re-fetching on view mode switches and re-renders.
*/
export const loadBookTargets = (
provider: string,
bookId: string,
): Promise<BookTargetOption[]> => {
const cached = getCached(provider, bookId);
if (cached) {
return Promise.resolve(cached);
}
return new Promise<BookTargetOption[]>((resolve, reject) => {
let list = pendingByProvider.get(provider);
if (!list) {
list = [];
pendingByProvider.set(provider, list);
}
list.push({ bookId, resolve, reject });
if (!flushScheduled) {
flushScheduled = true;
// Use queueMicrotask so all synchronous mounts in the same tick are batched
queueMicrotask(() => void flush());
}
});
};