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