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