Hardcover list separation + Browser download fix (#745)

- Added full Hardcover reading status types into the list selector
- Split reading status entries from dedicated lists
- Added option to disable the automatic removal of books when downloaded
from a Hardcover list
- Fixed browser download not firing when the completed state was
triggered in specific cases
This commit is contained in:
Alex
2026-03-13 14:33:10 +00:00
committed by GitHub
parent 3d72f9e258
commit 685c35d552
10 changed files with 221 additions and 127 deletions
+9 -9
View File
@@ -591,13 +591,11 @@ function App() {
});
// Check for completed items
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
const prevResolvingIds = new Set(Object.keys(prev.resolving || {}));
const prevQueuedIds = new Set(Object.keys(prevQueued));
const prevComplete = prev.complete || {};
const currComplete = curr.complete || {};
Object.keys(currComplete).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevComplete[bookId]) {
const book = currComplete[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
@@ -621,9 +619,10 @@ function App() {
});
// Check for failed items
const prevError = prev.error || {};
const currError = curr.error || {};
Object.keys(currError).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevResolvingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevError[bookId]) {
const book = currError[bookId];
const errorMsg = book.status_message || 'Download failed';
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
@@ -1115,18 +1114,19 @@ function App() {
metadataConfigRef.current = activeMetadataConfig;
const removeBookFromActiveList = useCallback((book: Book) => {
if (config?.hardcover_auto_remove_on_download === false) return;
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
// Only auto-remove from lists the user owns (My Books / My Lists)
// Only auto-remove from lists the user owns (Reading Status / My Lists)
const listField = metadataConfigRef.current?.search_fields.find(
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
);
if (listField && listField.type === 'DynamicSelectSearchField') {
const group = getDynamicOptionGroup(listField.options_endpoint, target);
if (group && group !== 'My Books' && group !== 'My Lists') return;
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
}
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
@@ -1141,7 +1141,7 @@ function App() {
showToast(`Removed from ${listName || 'list'}`, 'info');
}
}).catch(() => {});
}, [showToast]);
}, [config?.hardcover_auto_remove_on_download, showToast]);
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
@@ -2125,7 +2125,7 @@ function App() {
bottom: 0,
left: 0,
right: '25rem',
zIndex: 40,
zIndex: 20,
}
: { paddingTop: `${headerHeight}px` }
}
@@ -51,14 +51,23 @@ const renderSummary = (selectedOptions: DropdownListOption[]) => {
);
};
const STATUS_PREFIX = 'status:';
const isStatusTarget = (value: string): boolean => value.startsWith(STATUS_PREFIX);
const updateOptionChecked = (
prev: BookTargetOption[],
target: string,
checked: boolean,
): BookTargetOption[] =>
prev.map((option) =>
option.value === target ? { ...option, checked } : option,
);
prev.map((option) => {
if (option.value === target) return { ...option, checked };
// Statuses are mutually exclusive — uncheck other statuses when one is selected
if (checked && isStatusTarget(target) && isStatusTarget(option.value)) {
return { ...option, checked: false };
}
return option;
});
export const BookTargetDropdown = ({
provider,
@@ -136,6 +145,7 @@ export const BookTargetDropdown = ({
value: option.value,
label: option.label,
description: option.description,
group: option.group,
disabled: !option.writable || pendingTargets.has(option.value),
}));
}, [isLoading, loadError, options, pendingTargets]);
@@ -176,6 +186,16 @@ export const BookTargetDropdown = ({
target: toggledTarget,
selected: result.selected,
});
// When a status was implicitly deselected, sync other instances
if (result.deselectedTarget) {
setOptions((prev) => updateOptionChecked(prev, result.deselectedTarget!, false));
emitBookTargetChange({
provider,
bookId,
target: result.deselectedTarget,
selected: false,
});
}
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
@@ -232,7 +252,7 @@ export const BookTargetDropdown = ({
options={dropdownOptions}
value={selectedValues}
onChange={handleChange}
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
placeholder={isLoading ? 'Loading…' : 'Hardcover'}
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
panelClassName={variant !== 'default' ? 'w-56' : undefined}
+45 -31
View File
@@ -7,6 +7,7 @@ export interface DropdownListOption {
description?: string;
disabled?: boolean;
icon?: ReactNode;
group?: string;
}
interface DropdownListProps {
@@ -115,37 +116,50 @@ export const DropdownList = ({
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => (
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
))}
</div>
)}
{({ close }) => {
let lastGroup: string | undefined;
return (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => {
const showGroupHeader = option.group != null && option.group !== lastGroup;
if (option.group != null) lastGroup = option.group;
return (
<div key={option.value}>
{showGroupHeader && (
<div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
<button
type="button"
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
</div>
);
})}
</div>
);
}}
</Dropdown>
);
};
+32 -25
View File
@@ -274,7 +274,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
fetchFieldOptions(dynamicEndpoint).then((loaded) => {
if (cancelled) return;
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label })));
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label, group: o.group })));
setIsDynamicLoading(false);
}).catch(() => {
if (cancelled) return;
@@ -832,34 +832,41 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
aria-label={effectiveInputAriaLabel}
>
<div className="max-h-64 overflow-y-auto py-1.5">
{selectOptions.map((option) => {
{selectOptions.map((option, index) => {
const currentValue = typeof value === 'string' ? value : String(value ?? '');
const isSelected = option.value === currentValue;
const showGroupHeader = option.group != null && (index === 0 || option.group !== selectOptions[index - 1]?.group);
return (
<button
type="button"
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<div key={option.value}>
{showGroupHeader && (
<div className="px-5 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
</button>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
</div>
);
})}
</div>
@@ -22,6 +22,7 @@ const buildOptions = (
value: option.value,
label: option.label,
description: option.description,
group: option.group,
}));
};
+3 -1
View File
@@ -257,6 +257,7 @@ export interface BookTargetOption {
export interface BookTargetStateResult {
changed: boolean;
selected: boolean;
deselectedTarget?: string;
}
// Search metadata providers and normalize to Book format
@@ -423,7 +424,7 @@ export const setBookTargetState = async (
target: string,
selected: boolean,
): Promise<BookTargetStateResult> => {
const response = await fetchJSON<{ changed?: unknown; selected?: unknown }>(
const response = await fetchJSON<{ changed?: unknown; selected?: unknown; deselected_target?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`,
{
method: 'PUT',
@@ -434,6 +435,7 @@ export const setBookTargetState = async (
return {
changed: response.changed === true,
selected: response.selected === true,
deselectedTarget: typeof response.deselected_target === 'string' ? response.deselected_target : undefined,
};
};
+2
View File
@@ -115,6 +115,7 @@ export interface Toast {
export interface SortOption {
value: string;
label: string;
group?: string;
}
// Search field types (mirror backend search field types)
@@ -264,6 +265,7 @@ export interface AppConfig {
show_release_source_links: boolean;
books_output_mode: BooksOutputMode;
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
settings_enabled: boolean; // Whether config directory is mounted and writable
onboarding_complete: boolean; // Whether the user has completed initial setup