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