Feature: Combined book+audiobook downloads (#773)

- Adds a combined search option in the search bar selector
- Choose both a book and audiobook file in a two-step release modal, and
download both simultaneously from a single search result.
- Works for requests. Request both a book+audiobook at once, or works
seamlessly with request policies that differ between book + audiobook
(E.g. automatically download the ebook portion, while the audiobook gets
sent as a request)
- Hidden for users who have book or audiobooks blocked. 

Closes #611
This commit is contained in:
Alex
2026-03-16 18:34:46 +00:00
committed by GitHub
parent b464d62672
commit cb093f61c6
18 changed files with 1308 additions and 361 deletions
+341 -64
View File
@@ -27,7 +27,7 @@ import {
getStatus,
getMetadataProviders,
getMetadataSearchConfig,
createRequest,
createRequests,
isApiResponseError,
updateSelfUser,
setBookTargetState,
@@ -78,6 +78,7 @@ import {
getRequestSuccessMessage,
toContentType,
} from './utils/requestPayload';
import { applyRequestNoteToPayload } from './utils/requestConfirmation';
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
@@ -91,16 +92,19 @@ import './styles.css';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
const getInitialContentType = (): ContentType => {
const getInitialContentType = (): { contentType: ContentType; combinedMode: boolean } => {
try {
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
if (saved === 'combined') {
return { contentType: 'ebook', combinedMode: true };
}
if (saved === 'ebook' || saved === 'audiobook') {
return saved;
return { contentType: saved, combinedMode: false };
}
} catch {
// localStorage may be unavailable in private browsing
}
return 'ebook';
return { contentType: 'ebook', combinedMode: false };
};
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
@@ -142,6 +146,14 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE =
'Download queued, but the proxy interrupted the response. Status will refresh shortly.';
type CombinedSelectionState = {
phase: 'ebook' | 'audiobook';
ebookMode: RequestPolicyMode;
audiobookMode: RequestPolicyMode;
stagedEbook?: { book: Book; release: Release };
stagedAudiobook?: Release;
};
type PendingOnBehalfDownload =
| {
type: 'book';
@@ -154,6 +166,12 @@ type PendingOnBehalfDownload =
release: Release;
releaseContentType: ContentType;
actingAsUser: ActingAsUserSelection;
}
| {
type: 'combined';
book: Book;
combinedState: CombinedSelectionState;
actingAsUser: ActingAsUserSelection;
};
function App() {
@@ -214,15 +232,8 @@ function App() {
}, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]);
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
useEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType]);
const initialContentTypePref = useMemo(() => getInitialContentType(), []);
const [contentType, setContentType] = useState<ContentType>(initialContentTypePref.contentType);
const {
policy: requestPolicy,
@@ -256,6 +267,7 @@ function App() {
useEffect(() => {
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
setContentType(allowedContentTypes[0]);
setCombinedMode(false);
}
}, [allowedContentTypes, contentType]);
@@ -418,6 +430,7 @@ function App() {
}, [setBooks]);
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState<CreateRequestPayload[]>([]);
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(null);
const [fulfillingRequest, setFulfillingRequest] = useState<{
@@ -433,6 +446,7 @@ function App() {
clearTracking();
setActiveQueryTarget('general');
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
setActingAsUser(null);
setPendingOnBehalfDownload(null);
setFulfillingRequest(null);
@@ -452,10 +466,33 @@ function App() {
// UI state
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
// Combined mode state (ebook + audiobook in one transaction)
const [combinedMode, setCombinedMode] = useState(initialContentTypePref.combinedMode);
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
// Persist content type + combined mode to localStorage
useEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType, combinedMode]);
// Clear combined state when combined mode is turned off
// (combinedModeAllowed guard is in a separate effect below, after effectiveSearchMode is declared)
useEffect(() => {
if (!combinedMode) {
setCombinedState(null);
}
}, [combinedMode]);
const [config, setConfig] = useState<AppConfig | null>(null);
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<string | null>(null);
const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState<string | null>(null);
const [activeMetadataConfig, setActiveMetadataConfig] = useState<MetadataSearchConfig | null>(null);
const [activeQueryTarget, setActiveQueryTarget] = useState<string>('general');
const [activeResultsSort, setActiveResultsSort] = useState('');
@@ -645,11 +682,13 @@ function App() {
getConfig(),
getMetadataProviders(),
]);
const activeConfiguredProvider = getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider: metadataProviderState.configured_provider,
configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook,
});
const activeConfiguredProvider = combinedMode && metadataProviderState.configured_provider_combined
? metadataProviderState.configured_provider_combined
: getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider: metadataProviderState.configured_provider,
configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook,
});
let nextMetadataConfig: MetadataSearchConfig | null = null;
if (cfg.search_mode === 'universal') {
@@ -685,6 +724,7 @@ function App() {
setMetadataProviders(metadataProviderState.providers);
setConfiguredMetadataProvider(metadataProviderState.configured_provider);
setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook);
setConfiguredCombinedMetadataProvider(metadataProviderState.configured_provider_combined);
setActiveMetadataConfig(nextMetadataConfig);
// Show onboarding modal on first run (settings enabled but not completed yet)
@@ -716,7 +756,7 @@ function App() {
} catch (error) {
console.error('Failed to load config:', error);
}
}, [clearTracking, contentType, setAdvancedFilters, setBooks]);
}, [clearTracking, combinedMode, contentType, setAdvancedFilters, setBooks]);
// Fetch config when authenticated
useEffect(() => {
@@ -726,11 +766,32 @@ function App() {
}, [isAuthenticated, loadConfig]);
const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct';
const defaultMetadataProviderForContentType = getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider,
configuredAudiobookMetadataProvider,
});
// Combined mode requires universal mode, config enabled, and both content types accessible
const combinedModeAllowed = useMemo(() => {
if (effectiveSearchMode !== 'universal') return false;
if (config?.show_combined_selector === false) return false;
const ebookMode = getDefaultMode('ebook');
const audiobookMode = getDefaultMode('audiobook');
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
// Auto-disable combined mode if policy changes make it unavailable
// Skip while config is still loading to avoid resetting localStorage-restored state
useEffect(() => {
if (!config) return;
if (combinedMode && !combinedModeAllowed) {
setCombinedMode(false);
}
}, [config, combinedMode, combinedModeAllowed]);
const defaultMetadataProviderForContentType = combinedMode && configuredCombinedMetadataProvider
? configuredCombinedMetadataProvider
: getConfiguredMetadataProviderForContentType({
contentType,
configuredMetadataProvider,
configuredAudiobookMetadataProvider,
});
const effectiveMetadataProvider = effectiveSearchMode === 'universal'
? (defaultMetadataProviderForContentType || null)
: null;
@@ -1033,10 +1094,10 @@ function App() {
}
};
const submitRequest = useCallback(
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
const submitRequests = useCallback(
async (payloads: CreateRequestPayload[], successMessage: string): Promise<boolean> => {
try {
await createRequest(payload);
await createRequests(payloads);
await refreshActivitySnapshot();
showToast(successMessage, 'success');
await refreshRequestPolicy({ force: true });
@@ -1053,19 +1114,41 @@ function App() {
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
);
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
setPendingRequestPayload(payload);
}, []);
const openRequestConfirmation = useCallback((
payload: CreateRequestPayload,
extraPayloads: CreateRequestPayload[] = [],
onBehalfOfUserId: number | undefined = actingAsUser?.id,
) => {
const applyOnBehalf = (requestPayload: CreateRequestPayload): CreateRequestPayload => {
if (typeof onBehalfOfUserId !== 'number') {
return requestPayload;
}
return {
...requestPayload,
on_behalf_of_user_id: onBehalfOfUserId,
};
};
setPendingRequestPayload(applyOnBehalf(payload));
setPendingRequestExtraPayloads(extraPayloads.map(applyOnBehalf));
}, [actingAsUser?.id]);
const handleConfirmRequest = useCallback(
async (payload: CreateRequestPayload): Promise<boolean> => {
const success = await submitRequest(payload, getRequestSuccessMessage(payload));
if (success) {
setPendingRequestPayload(null);
}
return success;
async (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]): Promise<boolean> => {
const requestPayloads = [payload, ...(extraPayloads ?? pendingRequestExtraPayloads)].map((requestPayload) =>
applyRequestNoteToPayload(requestPayload, payload.note ?? '', allowRequestNotes)
);
const success = await submitRequests(
requestPayloads,
requestPayloads.length === 1 ? getRequestSuccessMessage(requestPayloads[0]) : 'Requests submitted',
);
if (!success) return false;
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
return true;
},
[submitRequest]
[allowRequestNotes, pendingRequestExtraPayloads, submitRequests]
);
const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => {
@@ -1076,6 +1159,20 @@ function App() {
return getDefaultMode(contentType);
}, [getDefaultMode, contentType]);
const getCombinedSelectionPhases = useCallback(
(state: Pick<CombinedSelectionState, 'ebookMode' | 'audiobookMode'>): ContentType[] => {
const phases: ContentType[] = [];
if (state.ebookMode !== 'request_book') {
phases.push('ebook');
}
if (state.audiobookMode !== 'request_book') {
phases.push('audiobook');
}
return phases;
},
[]
);
const buildReleaseDownloadPayload = useCallback(
(book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => {
const isManual = book.provider === 'manual';
@@ -1165,7 +1262,7 @@ function App() {
code: isApiResponseError(error) ? error.code : null,
});
if (requiredMode === 'request_release') {
openRequestConfirmation(buildDirectRequestPayload(book));
openRequestConfirmation(buildDirectRequestPayload(book), [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1229,7 +1326,7 @@ function App() {
content_type: normalizedContentType,
request_level: 'release',
},
});
}, [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1243,7 +1340,7 @@ function App() {
content_type: normalizedContentType,
request_level: 'book',
},
});
}, [], onBehalfOfUserId);
await refreshRequestPolicy({ force: true });
return;
}
@@ -1269,6 +1366,69 @@ function App() {
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease]
);
const executeCombinedAction = useCallback(
async (book: Book, selection: CombinedSelectionState, onBehalfOfUserId?: number): Promise<void> => {
const ebookRelease = selection.stagedEbook?.release;
const audiobookRelease = selection.stagedAudiobook;
const ebookMode = ebookRelease ? getSourceMode(ebookRelease.source, 'ebook') : selection.ebookMode;
const audiobookMode = audiobookRelease ? getSourceMode(audiobookRelease.source, 'audiobook') : selection.audiobookMode;
const buildRequestPayload = (
release: Release | undefined,
releaseContentType: ContentType,
mode: RequestPolicyMode,
): CreateRequestPayload => {
const payload = mode === 'request_release'
? {
book_data: buildMetadataBookRequestData(book, releaseContentType),
release_data: buildReleaseDataFromMetadataRelease(book, release!, releaseContentType),
context: {
source: release!.source,
content_type: releaseContentType,
request_level: 'release' as const,
},
}
: {
book_data: buildMetadataBookRequestData(book, releaseContentType),
release_data: null,
context: {
source: '*',
content_type: releaseContentType,
request_level: 'book' as const,
},
};
if (typeof onBehalfOfUserId !== 'number') {
return payload;
}
return {
...payload,
on_behalf_of_user_id: onBehalfOfUserId,
};
};
const requestPayloads: CreateRequestPayload[] = [];
if (ebookMode === 'download') {
await executeReleaseDownload(book, ebookRelease!, 'ebook', onBehalfOfUserId);
} else {
requestPayloads.push(buildRequestPayload(ebookRelease, 'ebook', ebookMode));
}
if (audiobookMode === 'download') {
await executeReleaseDownload(book, audiobookRelease!, 'audiobook', onBehalfOfUserId);
} else {
requestPayloads.push(buildRequestPayload(audiobookRelease, 'audiobook', audiobookMode));
}
if (requestPayloads.length > 0) {
openRequestConfirmation(requestPayloads[0], requestPayloads.slice(1), onBehalfOfUserId);
}
},
[executeReleaseDownload, getSourceMode, openRequestConfirmation]
);
const handleConfirmOnBehalfDownload = useCallback(async (): Promise<boolean> => {
if (!pendingOnBehalfDownload) {
return true;
@@ -1278,6 +1438,12 @@ function App() {
try {
if (pendingOnBehalfDownload.type === 'book') {
await executeBookDownload(pendingOnBehalfDownload.book, onBehalfOfUserId);
} else if (pendingOnBehalfDownload.type === 'combined') {
await executeCombinedAction(
pendingOnBehalfDownload.book,
pendingOnBehalfDownload.combinedState,
onBehalfOfUserId
);
} else {
await executeReleaseDownload(
pendingOnBehalfDownload.book,
@@ -1291,7 +1457,7 @@ function App() {
} catch {
return false;
}
}, [executeBookDownload, executeReleaseDownload, pendingOnBehalfDownload]);
}, [executeBookDownload, executeCombinedAction, executeReleaseDownload, pendingOnBehalfDownload]);
// Direct-mode action (download or release-level request based on policy).
const handleDownload = async (book: Book): Promise<void> => {
@@ -1413,22 +1579,52 @@ function App() {
return;
}
if (mode === 'request_book') {
policyTrace('universal.get:request_modal', {
bookId: book.id,
requestLevel: 'book',
contentType: normalizedContentType,
// Combined mode is only available when both default content types are accessible.
if (combinedMode) {
const latestPolicy2 = await refreshRequestPolicy({ force: true }).catch(() => null);
const effectiveIsAdmin2 = latestPolicy2 ? Boolean(latestPolicy2.is_admin) : requestRoleIsAdmin;
const ebookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'ebook');
const audiobookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'audiobook');
if (ebookMode === 'request_book' && audiobookMode === 'request_book') {
const ebookPayload: CreateRequestPayload = {
book_data: buildMetadataBookRequestData(book, 'ebook'),
release_data: null,
context: { source: '*', content_type: 'ebook', request_level: 'book' },
};
const audiobookPayload: CreateRequestPayload = {
book_data: buildMetadataBookRequestData(book, 'audiobook'),
release_data: null,
context: { source: '*', content_type: 'audiobook', request_level: 'book' },
};
openRequestConfirmation(ebookPayload, [audiobookPayload]);
return;
}
const selectionPhases = getCombinedSelectionPhases({ ebookMode, audiobookMode });
setCombinedState({
phase: selectionPhases[0],
ebookMode,
audiobookMode,
});
openRequestConfirmation({
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: null,
context: {
source: '*',
content_type: normalizedContentType,
request_level: 'book',
},
});
return;
} else {
if (mode === 'request_book') {
policyTrace('universal.get:request_modal', {
bookId: book.id,
requestLevel: 'book',
contentType: normalizedContentType,
});
openRequestConfirmation({
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: null,
context: {
source: '*',
content_type: normalizedContentType,
request_level: 'book',
},
});
return;
}
}
if (book.provider && book.provider_id) {
@@ -1525,6 +1721,53 @@ function App() {
return refreshRequestPolicy({ force: true });
}, [refreshRequestPolicy]);
// Combined mode callbacks
const handleCombinedNext = useCallback((release: Release) => {
if (!releaseBook || !combinedState) return;
const phases = getCombinedSelectionPhases(combinedState);
const nextPhase = phases[phases.indexOf(combinedState.phase) + 1];
setCombinedState({
...combinedState,
phase: nextPhase,
stagedEbook: { book: releaseBook, release },
});
}, [combinedState, getCombinedSelectionPhases, releaseBook]);
const handleCombinedBack = useCallback((audiobookRelease: Release | null) => {
setCombinedState((prev) => prev ? { ...prev, phase: 'ebook', stagedAudiobook: audiobookRelease ?? undefined } : null);
}, []);
const handleCombinedDownload = useCallback(async (release: Release) => {
if (!combinedState || !releaseBook) return;
const nextCombinedState: CombinedSelectionState = combinedState.phase === 'ebook'
? {
...combinedState,
stagedEbook: { book: releaseBook, release },
}
: {
...combinedState,
stagedAudiobook: release,
};
if (actingAsUser) {
setPendingOnBehalfDownload({
type: 'combined',
book: releaseBook,
combinedState: nextCombinedState,
actingAsUser,
});
setCombinedState(null);
setReleaseBook(null);
return;
}
await executeCombinedAction(releaseBook, nextCombinedState);
setCombinedState(null);
setReleaseBook(null);
}, [actingAsUser, combinedState, executeCombinedAction, releaseBook]);
const handleRequestCancel = useCallback(
async (requestId: number) => {
try {
@@ -1806,22 +2049,29 @@ function App() {
const handleSearchModeChange = useCallback((nextMode: SearchMode) => {
setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev);
if (nextMode !== 'universal') {
setCombinedMode(false);
}
updateSelfUser({ settings: { SEARCH_MODE: nextMode } })
.then(() => loadConfig('settings-saved'))
.catch((err) => console.error('Failed to save search mode:', err));
}, [loadConfig]);
const handleMetadataProviderChange = useCallback((provider: string) => {
if (contentType === 'audiobook') {
if (combinedMode) {
setConfiguredCombinedMetadataProvider(provider);
} else if (contentType === 'audiobook') {
setConfiguredAudiobookMetadataProvider(provider);
} else {
setConfiguredMetadataProvider(provider);
}
const key = contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER';
const key = combinedMode
? 'METADATA_PROVIDER_COMBINED'
: contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER';
updateSelfUser({ settings: { [key]: provider } })
.then(() => loadConfig('settings-saved'))
.catch((err) => console.error('Failed to save metadata provider:', err));
}, [contentType, loadConfig]);
}, [combinedMode, contentType, loadConfig]);
const buildCurrentSearchRequest = useCallback((sortOverride?: string) => {
const appliedSort = effectiveSearchMode === 'universal'
@@ -2034,7 +2284,15 @@ function App() {
const isBrowseFulfilMode = fulfillingRequest !== null;
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType;
const activeReleaseContentType = fulfillingRequest?.contentType ?? combinedState?.phase ?? contentType;
const combinedSelectionPhases = combinedState ? getCombinedSelectionPhases(combinedState) : [];
const combinedCurrentStep = combinedState ? combinedSelectionPhases.indexOf(combinedState.phase) + 1 : 0;
const combinedIsFinalStep = combinedState
? combinedSelectionPhases[combinedSelectionPhases.length - 1] === combinedState.phase
: false;
const combinedHasPreviousStep = combinedState
? combinedSelectionPhases.indexOf(combinedState.phase) > 0
: false;
const usePinnedMainScrollContainer = sidebarPinnedOpen;
const handleReleaseModalClose = useCallback(() => {
@@ -2042,15 +2300,18 @@ function App() {
setFulfillingRequest(null);
return;
}
setCombinedState(null);
setReleaseBook(null);
}, [isBrowseFulfilMode]);
const pendingOnBehalfTitle = pendingOnBehalfDownload
? pendingOnBehalfDownload.type === 'book'
? pendingOnBehalfDownload.book.title || 'Untitled'
: pendingOnBehalfDownload.release.title ||
pendingOnBehalfDownload.book.title ||
'Untitled'
: pendingOnBehalfDownload.type === 'combined'
? pendingOnBehalfDownload.book.title || 'Untitled'
: pendingOnBehalfDownload.release.title ||
pendingOnBehalfDownload.book.title ||
'Untitled'
: '';
const pendingOnBehalfUserName = pendingOnBehalfDownload
? formatActingAsUserName(pendingOnBehalfDownload.actingAsUser)
@@ -2104,6 +2365,8 @@ function App() {
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={setActiveQueryTarget}
@@ -2142,6 +2405,7 @@ function App() {
activeMetadataProvider={effectiveMetadataProvider}
onMetadataProviderChange={handleMetadataProviderChange}
contentType={contentType}
combinedMode={combinedMode}
isAdmin={requestRoleIsAdmin}
onClose={() => setShowAdvanced(false)}
/>
@@ -2180,6 +2444,8 @@ function App() {
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
activeQueryField={activeQueryField}
searchMode={effectiveSearchMode}
onSearchModeChange={handleSearchModeChange}
@@ -2272,15 +2538,26 @@ function App() {
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
showReleaseSourceLinks={config?.show_release_source_links !== false}
onShowToast={showToast}
combinedPhase={combinedState?.phase ?? null}
combinedCurrentStep={combinedCurrentStep}
combinedTotalSteps={combinedSelectionPhases.length}
combinedEbookMode={combinedState?.ebookMode ?? null}
combinedAudiobookMode={combinedState?.audiobookMode ?? null}
onCombinedNext={combinedState && !combinedIsFinalStep ? handleCombinedNext : undefined}
onCombinedBack={combinedState && combinedHasPreviousStep ? handleCombinedBack : undefined}
onCombinedDownload={combinedState && combinedIsFinalStep ? handleCombinedDownload : undefined}
stagedEbookRelease={combinedState?.stagedEbook?.release ?? null}
stagedAudiobookRelease={combinedState?.stagedAudiobook ?? null}
/>
)}
{pendingRequestPayload && (
<RequestConfirmationModal
payload={pendingRequestPayload}
extraPayloads={pendingRequestExtraPayloads}
allowNotes={allowRequestNotes}
onConfirm={handleConfirmRequest}
onClose={() => setPendingRequestPayload(null)}
onClose={() => { setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); }}
/>
)}
+24 -24
View File
@@ -27,6 +27,7 @@ interface AdvancedFiltersProps {
activeMetadataProvider?: string | null;
onMetadataProviderChange?: (provider: string) => void;
contentType?: ContentType;
combinedMode?: boolean;
isAdmin?: boolean;
onClose?: () => void;
}
@@ -50,6 +51,7 @@ export const AdvancedFilters = ({
activeMetadataProvider,
onMetadataProviderChange,
contentType = 'ebook',
combinedMode = false,
isAdmin = false,
onClose,
}: AdvancedFiltersProps) => {
@@ -121,35 +123,33 @@ export const AdvancedFilters = ({
</div>
)}
{isAdmin && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<DropdownList
label="Search Mode"
options={SEARCH_MODE_OPTIONS}
value={searchMode}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
}}
placeholder="Choose a mode"
widthClassName="w-full"
/>
{searchMode === 'universal' && (
<DropdownList
label="Search Mode"
options={SEARCH_MODE_OPTIONS}
value={searchMode}
label={combinedMode ? 'Combined Metadata Provider' : contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
options={providerOptions}
value={activeMetadataProvider ?? ''}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
const next = Array.isArray(value) ? value[0] ?? '' : value;
onMetadataProviderChange?.(next);
}}
placeholder="Choose a mode"
placeholder="Choose a provider"
widthClassName="w-full"
/>
{searchMode === 'universal' && (
<DropdownList
label={contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
options={providerOptions}
value={activeMetadataProvider ?? ''}
onChange={(value) => {
const next = Array.isArray(value) ? value[0] ?? '' : value;
onMetadataProviderChange?.(next);
}}
placeholder="Choose a provider"
widthClassName="w-full"
/>
)}
</div>
</>
)}
</div>
)}
{searchMode === 'direct' && (
+6
View File
@@ -42,6 +42,8 @@ interface HeaderProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
onQueryTargetChange?: (target: string) => void;
@@ -79,6 +81,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode,
onCombinedModeChange,
queryTargets = [],
activeQueryTarget = 'general',
onQueryTargetChange,
@@ -671,6 +675,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={onQueryTargetChange}
+257 -61
View File
@@ -101,6 +101,17 @@ interface ReleaseModalProps {
isRequestMode?: boolean;
showReleaseSourceLinks?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
// Combined mode props
combinedPhase?: 'ebook' | 'audiobook' | null;
combinedCurrentStep?: number;
combinedTotalSteps?: number;
combinedEbookMode?: RequestPolicyMode | null;
combinedAudiobookMode?: RequestPolicyMode | null;
onCombinedNext?: (release: Release) => void;
onCombinedBack?: (audiobookRelease: Release | null) => void;
onCombinedDownload?: (release: Release) => void;
stagedEbookRelease?: Release | null;
stagedAudiobookRelease?: Release | null;
}
@@ -217,6 +228,25 @@ const LeadingCell = ({
return <ReleaseThumbnail preview={undefined} title={release.title} />;
};
// Radio indicator for selection mode
const RadioIndicator = ({ selected }: { selected: boolean }) => (
<div className="flex items-center justify-center w-8 h-8 shrink-0">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
selected
? 'border-emerald-500 bg-emerald-500'
: 'border-zinc-300 dark:border-zinc-600'
}`}
>
{selected && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" strokeWidth="3" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</div>
</div>
);
// Release row component with dynamic columns
const ReleaseRow = ({
release,
@@ -228,6 +258,9 @@ const ReleaseRow = ({
leadingCell,
onlineServers,
showReleaseSourceLinks,
selectionMode = false,
isSelected = false,
onSelect,
}: {
release: Release;
index: number;
@@ -238,6 +271,9 @@ const ReleaseRow = ({
leadingCell?: LeadingCellConfig;
onlineServers?: string[];
showReleaseSourceLinks: boolean;
selectionMode?: boolean;
isSelected?: boolean;
onSelect?: () => void;
}) => {
const author = release.extra?.author as string | undefined;
@@ -257,13 +293,20 @@ const ReleaseRow = ({
? 'auto 1fr auto'
: '1fr auto';
const handleRowClick = selectionMode && onSelect ? onSelect : undefined;
return (
<div
className="pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
className={`pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform ${
selectionMode ? 'cursor-pointer' : ''
} ${isSelected ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : ''}`}
style={{
animationDelay: `${index * 30}ms`,
animationFillMode: 'both',
}}
onClick={handleRowClick}
role={selectionMode ? 'option' : undefined}
aria-selected={selectionMode ? isSelected : undefined}
>
{/* Desktop layout with dynamic grid */}
<div
@@ -302,14 +345,18 @@ const ReleaseRow = ({
<ReleaseCell key={col.key} column={col} release={release} onlineServers={onlineServers} />
))}
{/* Fixed: Action button */}
<BookDownloadButton
buttonState={buttonState}
onDownload={onDownload}
variant="icon"
size="sm"
ariaLabel={`${buttonState.text} ${release.title}`}
/>
{/* Fixed: Action button or radio indicator */}
{selectionMode ? (
<RadioIndicator selected={isSelected} />
) : (
<BookDownloadButton
buttonState={buttonState}
onDownload={onDownload}
variant="icon"
size="sm"
ariaLabel={`${buttonState.text} ${release.title}`}
/>
)}
</div>
{/* Mobile layout - author inline with title, info line below */}
@@ -379,13 +426,17 @@ const ReleaseRow = ({
)}
</div>
<BookDownloadButton
buttonState={buttonState}
onDownload={onDownload}
variant="icon"
size="sm"
ariaLabel={`${buttonState.text} ${release.title}`}
/>
{selectionMode ? (
<RadioIndicator selected={isSelected} />
) : (
<BookDownloadButton
buttonState={buttonState}
onDownload={onDownload}
variant="icon"
size="sm"
ariaLabel={`${buttonState.text} ${release.title}`}
/>
)}
</div>
</div>
);
@@ -540,6 +591,16 @@ export const ReleaseModal = ({
isRequestMode = false,
showReleaseSourceLinks = true,
onShowToast,
combinedPhase = null,
combinedCurrentStep = 1,
combinedTotalSteps = 2,
combinedEbookMode = null,
combinedAudiobookMode = null,
onCombinedNext,
onCombinedBack,
onCombinedDownload,
stagedEbookRelease = null,
stagedAudiobookRelease = null,
}: ReleaseModalProps) => {
// Use audiobook formats when in audiobook mode
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
@@ -550,6 +611,8 @@ export const ReleaseModal = ({
: defaultReleaseSource;
const [isClosing, setIsClosing] = useState(false);
const [isRequestingBook, setIsRequestingBook] = useState(false);
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null);
const isCombinedMode = combinedPhase !== null;
// Available sources from plugin registry
const [availableSources, setAvailableSources] = useState<ReleaseSource[]>([]);
@@ -646,6 +709,15 @@ export const ReleaseModal = ({
}
}, [book]);
// Restore staged selection when navigating between phases
useEffect(() => {
if (combinedPhase === 'ebook' && stagedEbookRelease) {
setSelectedRelease(stagedEbookRelease);
} else if (combinedPhase === 'audiobook' && stagedAudiobookRelease) {
setSelectedRelease(stagedAudiobookRelease);
}
}, [combinedPhase, stagedEbookRelease, stagedAudiobookRelease]);
// Reset modal state when book changes to prevent stale data
useEffect(() => {
setDescriptionExpanded(false);
@@ -659,6 +731,11 @@ export const ReleaseModal = ({
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setIndexerFilter([]);
indexerFilterInitializedRef.current = new Set();
// Don't clear selectedRelease here — the combinedPhase effect handles it
// (restoring the staged ebook selection when going back)
if (!isCombinedMode) {
setSelectedRelease(null);
}
const baseTitle = book?.search_title || book?.title || '';
const baseAuthor = book?.search_author || book?.author || '';
const defaultQuery = `${baseTitle} ${baseAuthor}`.trim();
@@ -671,7 +748,7 @@ export const ReleaseModal = ({
clearTimeout(statusTimeoutRef.current);
statusTimeoutRef.current = null;
}
}, [book?.id, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]);
}, [book?.id, contentType, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]);
// Set up WebSocket listener for search status updates
useEffect(() => {
@@ -774,7 +851,7 @@ export const ReleaseModal = ({
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [book]);
// Fetch available sources on mount
// Fetch available sources on mount (only when book changes, not content type)
useEffect(() => {
if (!book) return;
@@ -784,47 +861,9 @@ export const ReleaseModal = ({
setSourcesError(null);
const sources = await getReleaseSources();
setAvailableSources(sources);
const providerContextSource = sources.find((source) => (
source.name === book.provider && source.browse_results_are_releases
));
// Filter sources by content type support
const supportedSources = sources.filter(s => {
const types = s.supported_content_types || ['ebook', 'audiobook'];
return types.includes(contentType);
});
if (providerContextSource) {
setActiveTab(providerContextSource.name);
return;
}
// Set active tab: prefer the configured default source if enabled and supports content type
if (supportedSources.length > 0) {
const enabledSources = supportedSources.filter(s => s.enabled);
const defaultIsEnabled = preferredDefaultReleaseSource &&
enabledSources.some(s => s.name === preferredDefaultReleaseSource);
let defaultSource: string;
if (defaultIsEnabled) {
defaultSource = preferredDefaultReleaseSource;
} else if (enabledSources.length > 0) {
defaultSource = enabledSources[0].name;
} else {
defaultSource = supportedSources[0].name; // Fallback to first supported source
}
setActiveTab(defaultSource);
} else if (sources.length > 0) {
// No sources support this content type - fall back to first source
setActiveTab(sources[0].name);
} else {
setActiveTab('');
}
} catch (err) {
console.error('Failed to fetch release sources:', err);
setAvailableSources([]);
setActiveTab('');
setSourcesError(err instanceof Error ? err.message : 'Failed to load release sources');
} finally {
setSourcesLoading(false);
@@ -832,7 +871,46 @@ export const ReleaseModal = ({
};
fetchSources();
}, [book, preferredDefaultReleaseSource, contentType]);
}, [book]);
// Pick default active tab when sources or content type changes (synchronous, no flash)
useEffect(() => {
if (availableSources.length === 0) return;
const providerContextSource = availableSources.find((source) => (
source.name === book?.provider && source.browse_results_are_releases
));
if (providerContextSource) {
setActiveTab(providerContextSource.name);
return;
}
const supportedSources = availableSources.filter(s => {
const types = s.supported_content_types || ['ebook', 'audiobook'];
return types.includes(contentType);
});
if (supportedSources.length > 0) {
const enabledSources = supportedSources.filter(s => s.enabled);
const defaultIsEnabled = preferredDefaultReleaseSource &&
enabledSources.some(s => s.name === preferredDefaultReleaseSource);
let defaultSource: string;
if (defaultIsEnabled) {
defaultSource = preferredDefaultReleaseSource;
} else if (enabledSources.length > 0) {
defaultSource = enabledSources[0].name;
} else {
defaultSource = supportedSources[0].name;
}
setActiveTab(defaultSource);
} else if (availableSources.length > 0) {
setActiveTab(availableSources[0].name);
} else {
setActiveTab('');
}
}, [availableSources, book?.provider, preferredDefaultReleaseSource, contentType]);
// Fetch releases when active tab changes (with caching)
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
@@ -1262,6 +1340,16 @@ export const ReleaseModal = ({
return;
}
// In combined mode, clicking a row selects it (don't download)
if (isCombinedMode) {
const mode = getReleaseActionMode(release);
if (mode === 'blocked' || mode === 'request_book') {
return;
}
setSelectedRelease(release);
return;
}
const mode = getReleaseActionMode(release);
if (mode === 'download') {
await onDownload(book, release, contentType);
@@ -1275,10 +1363,8 @@ export const ReleaseModal = ({
}
return;
}
// blocked / request_book — should not be reachable (button is disabled),
// but guard defensively.
},
[book, getReleaseActionMode, onDownload, onRequestRelease, contentType, handleClose]
[book, isCombinedMode, getReleaseActionMode, onDownload, onRequestRelease, contentType, handleClose]
);
if (!book && !isClosing) return null;
@@ -1370,7 +1456,9 @@ export const ReleaseModal = ({
)}
<div className="flex-1 space-y-1 min-w-0">
<p className="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Find Releases
{isCombinedMode
? `Step ${combinedCurrentStep} of ${combinedTotalSteps} — Select ${combinedPhase === 'ebook' ? 'book' : 'audiobook'}`
: 'Find Releases'}
</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}
@@ -2037,6 +2125,9 @@ export const ReleaseModal = ({
leadingCell={columnConfig.leading_cell}
onlineServers={columnConfig.online_servers}
showReleaseSourceLinks={showReleaseSourceLinks}
selectionMode={isCombinedMode}
isSelected={isCombinedMode && selectedRelease?.source_id === release.source_id}
onSelect={isCombinedMode ? () => setSelectedRelease(release) : undefined}
/>
))}
</div>
@@ -2084,6 +2175,111 @@ export const ReleaseModal = ({
</div>
)}
</div>
{/* Combined mode footer */}
{isCombinedMode && (
<div className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-3">
{/* Phase indicators with live selection chips */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-1 sm:gap-3 text-sm min-w-0">
{(() => {
const ebookRelease = combinedPhase === 'ebook' ? selectedRelease : stagedEbookRelease;
const audiobookRelease = combinedPhase === 'audiobook' ? selectedRelease : stagedAudiobookRelease;
return (
<>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
ebookRelease
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400'
: combinedPhase === 'ebook'
? 'bg-zinc-100 dark:bg-zinc-800 text-(--text)'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
}`}>
{ebookRelease ? (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{ebookRelease.format?.toUpperCase() || 'Book'} · {ebookRelease.size || '?'}
</>
) : (
<>{combinedPhase === 'ebook' ? '\u25CF' : '\u25CB'} Book</>
)}
</span>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
audiobookRelease
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400'
: combinedPhase === 'audiobook'
? 'bg-zinc-100 dark:bg-zinc-800 text-(--text)'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
}`}>
{audiobookRelease ? (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{audiobookRelease.format?.toUpperCase() || 'Audiobook'} · {audiobookRelease.size || '?'}
</>
) : (
<>{combinedPhase === 'audiobook' ? '\u25CF' : '\u25CB'} Audiobook</>
)}
</span>
</>
);
})()}
</div>
{/* Action buttons */}
<div className="flex items-center justify-end gap-3 shrink-0">
{onCombinedBack && (
<button
type="button"
onClick={() => { const picked = selectedRelease; setSelectedRelease(stagedEbookRelease); onCombinedBack!(picked); }}
className="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors hover-surface text-(--text)"
>
&larr; Back
</button>
)}
{combinedPhase === 'ebook' && onCombinedNext && (
<button
type="button"
onClick={() => { if (selectedRelease) { const picked = selectedRelease; setSelectedRelease(stagedAudiobookRelease); onCombinedNext(picked); } }}
disabled={!selectedRelease}
className="px-4 py-1.5 text-sm font-medium text-white rounded-lg transition-colors bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Select Audiobook &rarr;
</button>
)}
{onCombinedDownload && (() => {
const ebookActionMode = combinedPhase === 'ebook'
? (selectedRelease ? getReleaseActionMode(selectedRelease) : combinedEbookMode)
: (stagedEbookRelease ? getReleaseActionMode(stagedEbookRelease) : combinedEbookMode);
const audiobookActionMode = combinedPhase === 'audiobook'
? (selectedRelease ? getReleaseActionMode(selectedRelease) : combinedAudiobookMode)
: (stagedAudiobookRelease ? getReleaseActionMode(stagedAudiobookRelease) : combinedAudiobookMode);
const ebookIsRequest = ebookActionMode === 'request_release' || ebookActionMode === 'request_book';
const audiobookIsRequest = audiobookActionMode === 'request_release' || audiobookActionMode === 'request_book';
const label = ebookIsRequest && audiobookIsRequest
? 'Request Both'
: ebookIsRequest || audiobookIsRequest
? 'Download & Request'
: 'Download Both';
return (
<button
type="button"
onClick={() => selectedRelease && onCombinedDownload(selectedRelease)}
disabled={!selectedRelease}
className="px-4 py-1.5 text-sm font-medium text-white rounded-lg transition-colors bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{label}
</button>
);
})()}
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -13,13 +13,15 @@ import {
interface RequestConfirmationModalProps {
payload: CreateRequestPayload | null;
extraPayloads?: CreateRequestPayload[];
allowNotes: boolean;
onConfirm: (payload: CreateRequestPayload) => Promise<boolean>;
onConfirm: (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]) => Promise<boolean>;
onClose: () => void;
}
export const RequestConfirmationModal = ({
payload,
extraPayloads = [],
allowNotes,
onConfirm,
onClose,
@@ -71,6 +73,10 @@ export const RequestConfirmationModal = ({
return payload ? buildRequestConfirmationPreview(payload) : null;
}, [payload]);
const extraPreviews = useMemo(() => {
return extraPayloads.map(buildRequestConfirmationPreview);
}, [extraPayloads]);
const [enriched, setEnriched] = useState<RequestConfirmationPreview | null>(null);
const enrichRef = useRef(0);
@@ -122,7 +128,7 @@ export const RequestConfirmationModal = ({
setIsSubmitting(true);
try {
const nextPayload = applyRequestNoteToPayload(payload, note, allowNotes);
const success = await onConfirm(nextPayload);
const success = await onConfirm(nextPayload, extraPayloads.length > 0 ? extraPayloads : undefined);
if (!success) {
setIsSubmitting(false);
}
@@ -147,7 +153,7 @@ export const RequestConfirmationModal = ({
>
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Request Book
{extraPayloads.length > 0 ? 'Request Book & Audiobook' : 'Request Book'}
</h3>
<button
type="button"
@@ -194,8 +200,24 @@ export const RequestConfirmationModal = ({
)}
</div>
)}
{preview.releaseLine && (
<p className="text-xs opacity-60 mt-1.5">{preview.releaseLine}</p>
{/* Release lines — show all (primary + extras) with content type labels when combined */}
{(preview.releaseLine || extraPreviews.length > 0) && (
<div className="mt-1.5 space-y-0.5">
{preview.releaseLine && (
<p className="text-xs opacity-60">
{extraPreviews.length > 0 && (
<span className="font-medium opacity-80">{payload.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
)}
{preview.releaseLine}
</p>
)}
{extraPreviews.map((ep, i) => ep.releaseLine && (
<p key={i} className="text-xs opacity-60">
<span className="font-medium opacity-80">{extraPayloads[i]?.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
{ep.releaseLine}
</p>
))}
</div>
)}
</div>
</div>
+76 -16
View File
@@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import { useSearchMode } from '../contexts/SearchModeContext';
import { Tooltip } from './shared/Tooltip';
import {
ContentType,
MetadataSearchField,
@@ -41,6 +42,8 @@ interface SearchBarProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
onQueryTargetChange?: (target: string) => void;
@@ -88,17 +91,23 @@ const autocompleteOptionsCache = new Map<string, DynamicFieldOption[]>();
const AUTOCOMPLETE_CACHE_MAX = 100;
const BookIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
const AudiobookIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
);
const BothIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
);
const CheckIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg className={className} 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" />
@@ -109,10 +118,12 @@ const getDefaultPlaceholder = (
contentType: ContentType,
activeQueryTarget: QueryTargetOption | undefined,
fallback?: string,
isCombinedMode?: boolean,
): string => {
if (fallback) return fallback;
if (!activeQueryTarget || activeQueryTarget.source === 'general') {
if (isCombinedMode) return 'Search Books & Audiobooks (combined)';
return contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks';
}
@@ -167,6 +178,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode = false,
onCombinedModeChange,
queryTargets = [],
activeQueryTarget = 'general',
onQueryTargetChange,
@@ -396,6 +409,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const handleContentTypeSelect = (type: ContentType) => {
onContentTypeChange?.(type);
onCombinedModeChange?.(false);
setIsSelectorOpen(false);
};
const handleCombinedModeSelect = () => {
if (combinedMode) {
// Toggle off — revert to ebook-only
onCombinedModeChange?.(false);
} else {
onContentTypeChange?.('ebook');
onCombinedModeChange?.(true);
}
setIsSelectorOpen(false);
};
@@ -404,7 +429,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
setIsSelectorOpen(false);
};
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder);
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder, combinedMode);
const effectiveInputAriaLabel = activeTarget
? `${inputAriaLabel}: ${activeTarget.label}`
: inputAriaLabel;
@@ -611,11 +636,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
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.`}
aria-label={`Searching ${combinedMode ? 'books and audiobooks' : contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
aria-expanded={isSelectorOpen}
aria-haspopup="dialog"
>
{contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
{combinedMode ? <BothIcon /> : contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
{showActiveTargetLabel && (
<span className="hidden max-w-24 truncate text-sm font-medium sm:inline">
{activeTarget?.label}
@@ -650,7 +675,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
{showContentTypeSelector && (
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className={`border-b ${onCombinedModeChange ? 'pb-0' : 'pb-3'}`} style={{ borderColor: 'var(--border-muted)' }}>
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Content</span>
{onAdvancedToggle && (
@@ -693,29 +718,64 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
contentType === 'ebook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'ebook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
style={contentType === 'ebook' || combinedMode
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
{contentType === 'ebook' ? <CheckIcon /> : <BookIcon />}
{contentType === 'ebook' || combinedMode ? <CheckIcon /> : <BookIcon />}
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
contentType === 'audiobook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'audiobook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
style={contentType === 'audiobook' || combinedMode
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
{contentType === 'audiobook' ? <CheckIcon /> : <AudiobookIcon />}
{contentType === 'audiobook' || combinedMode ? <CheckIcon /> : <AudiobookIcon />}
<span>Audiobooks</span>
</button>
</div>
{onCombinedModeChange && (() => {
const lineColor = combinedMode ? 'bg-emerald-500' : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
return (
<Tooltip content="Combined search" position="bottom" triggerClassName="w-full">
<button
type="button"
onClick={handleCombinedModeSelect}
className="group w-full"
aria-label="Combined search"
>
{/* Bracket connector: vertical drops + horizontal bar with icon */}
<div className="relative flex items-end h-7">
{/* Left vertical */}
<div className={`absolute left-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
{/* Right vertical */}
<div className={`absolute right-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
{/* Horizontal bar – left segment */}
<div className={`absolute left-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
{/* Horizontal bar – right segment */}
<div className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
{/* Chain icon centered at bottom */}
<div className={`mx-auto relative z-10 p-1 rounded-full transition-colors ${
combinedMode
? 'bg-emerald-600 text-white'
: 'bg-(--bg) text-zinc-400 dark:text-zinc-500 group-hover:bg-zinc-200 dark:group-hover:bg-zinc-700 group-hover:text-zinc-600 dark:group-hover:text-zinc-300'
}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
</div>
</button>
</Tooltip>
);
})()}
</div>
)}
@@ -30,6 +30,8 @@ interface SearchSectionProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
activeQueryField?: MetadataSearchField | null;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
@@ -59,6 +61,8 @@ export const SearchSection = ({
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
combinedMode,
onCombinedModeChange,
activeQueryField,
searchMode,
onSearchModeChange,
@@ -96,6 +100,8 @@ export const SearchSection = ({
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
onQueryTargetChange={onQueryTargetChange}
@@ -120,6 +126,7 @@ export const SearchSection = ({
activeMetadataProvider={activeMetadataProvider}
onMetadataProviderChange={onMetadataProviderChange}
contentType={contentType}
combinedMode={combinedMode}
isAdmin={isAdmin}
onClose={onAdvancedToggle}
/>
+8
View File
@@ -49,6 +49,7 @@ const API = {
settings: `${API_BASE}/settings`,
requestPolicy: `${API_BASE}/request-policy`,
requests: `${API_BASE}/requests`,
requestsBatch: `${API_BASE}/requests/batch`,
adminRequests: `${API_BASE}/admin/requests`,
adminRequestCounts: `${API_BASE}/admin/requests/count`,
activitySnapshot: `${API_BASE}/activity/snapshot`,
@@ -591,6 +592,13 @@ export const createRequest = async (payload: CreateRequestPayload): Promise<Requ
});
};
export const createRequests = async (payloads: CreateRequestPayload[]): Promise<RequestRecord[]> => {
return fetchJSON<RequestRecord[]>(API.requestsBatch, {
method: 'POST',
body: JSON.stringify({ requests: payloads }),
});
};
export const listRequests = async (params: ListRequestsParams = {}): Promise<RequestRecord[]> => {
const url = buildRequestListUrl(API.requests, params);
return fetchJSON<RequestRecord[]>(url);
+3
View File
@@ -221,6 +221,7 @@ export interface CreateRequestPayload {
book_data: Record<string, unknown>;
release_data?: Record<string, unknown> | null;
note?: string;
on_behalf_of_user_id?: number;
context: RequestContextPayload;
}
@@ -264,6 +265,7 @@ export interface AppConfig {
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
show_release_source_links: boolean;
show_combined_selector: 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
@@ -286,6 +288,7 @@ export interface MetadataProvidersResponse {
providers: MetadataProviderSummary[];
configured_provider: string | null;
configured_provider_audiobook: string | null;
configured_provider_combined: string | null;
}
export interface MetadataCapability {