mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 13:38:31 +00:00
Feature: Show the AA search result stats (#1362)
When doing a direct search for a book, show the stats of the AA results. For example if we search for "The Great Gatsby", AA reports it has 240 hits and shows the first page of 50. Provide this stats info in the shelfmark webUI via ResultsSection.tsx and ReleaseModal.tsx. This table shows what should be displayed based on the total number of hits found: |Total |Display| |-------|-------| |1 |"Result 1 (1 Total)"| |6 |"Results 1-6 (6 Total)"| |144 |"Results 1-50 (144 Total)"| |500+ |"Results 1-50 (500+ Total)"| Currently, shelfmark also only shows us the first 50 search hits even if there were more available from AA. This could be added later if considered desirable. As usual, a picture is worth a 1000 words: <img width="1012" height="610" alt="direct-results-stat" src="https://github.com/user-attachments/assets/8c0c688f-444e-482a-a9a3-2dee5a5563b8" /> <img width="1013" height="741" alt="universal-results-info" src="https://github.com/user-attachments/assets/399bbb79-87f2-4432-a3d0-64937795f5f1" /> Coded with llama.cpp, opencode and 🤖
This commit is contained in:
1 parent
c1315a2b23
commit
d4619be69a
14 files changed
+181
-56
No files matched your search
@@ -487,6 +487,7 @@ function App() {
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
totalFound,
|
||||
directTotalResults,
|
||||
resultsSourceUrl,
|
||||
} = useSearch({
|
||||
showToast,
|
||||
@@ -2607,6 +2608,7 @@ function App() {
|
||||
void loadMore(config, effectiveSearchMode);
|
||||
}}
|
||||
totalFound={totalFound}
|
||||
directTotalResults={directTotalResults}
|
||||
onShowToast={showToast}
|
||||
resultsSourceUrl={resultsSourceUrl}
|
||||
/>
|
||||
|
||||
@@ -1769,7 +1769,7 @@ const ReleaseModalSession = ({
|
||||
</div>
|
||||
)}
|
||||
{!sourcesLoading && allTabs.length > 0 && (
|
||||
<div className="flex items-center justify-between px-5">
|
||||
<div className="flex items-center px-5">
|
||||
{/* Tabs - scrollable on narrow screens */}
|
||||
<div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="relative flex gap-1">
|
||||
@@ -1801,7 +1801,35 @@ const ReleaseModalSession = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pr-1 pl-2">
|
||||
{/* Center: Results count from release sources */}
|
||||
<div className="flex flex-1 justify-center">
|
||||
{(() => {
|
||||
const searchInfo = releasesBySource[activeTab]?.search_info?.[activeTab];
|
||||
const totalCount = searchInfo?.total_results;
|
||||
if (totalCount === undefined || totalCount === null || totalCount === 0)
|
||||
return null;
|
||||
const isCapped = totalCount === '500+';
|
||||
const totalCountNum = isCapped ? 500 : Number(totalCount);
|
||||
// AA-style: show page range + total
|
||||
if (totalCountNum === 1) {
|
||||
return (
|
||||
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
Result 1 (1 Total)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const shownEnd = Math.min(totalCountNum, 50);
|
||||
const totalStr = isCapped ? '500+' : String(totalCountNum);
|
||||
return (
|
||||
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
Results 1-{shownEnd} ({totalStr} Total)
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="ml-auto flex items-center gap-3 pr-1 pl-2">
|
||||
{/* Multi-book pack toggle (fallback for releases that can't be inspected) */}
|
||||
{!isCombinedMode && (
|
||||
<button
|
||||
|
||||
@@ -33,6 +33,8 @@ interface ResultsSectionProps {
|
||||
isLoadingMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
totalFound?: number;
|
||||
// Direct mode: total result count from release sources
|
||||
directTotalResults?: number | string | null;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
resultsSourceUrl?: string;
|
||||
}
|
||||
@@ -53,6 +55,7 @@ export const ResultsSection = ({
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
totalFound,
|
||||
directTotalResults,
|
||||
onShowToast,
|
||||
resultsSourceUrl,
|
||||
}: ResultsSectionProps) => {
|
||||
@@ -88,34 +91,62 @@ export const ResultsSection = ({
|
||||
|
||||
return (
|
||||
<section id="results-section" className="mb-4 w-full sm:mb-8">
|
||||
<div className="relative z-10 mb-2 flex items-center justify-between sm:mb-3">
|
||||
{showSortControl && (
|
||||
<SortControl
|
||||
value={sortValue}
|
||||
onChange={onSortChange}
|
||||
metadataSortOptions={metadataSortOptions}
|
||||
/>
|
||||
)}
|
||||
{!showSortControl && resultsSourceUrl && (
|
||||
<a
|
||||
href={resultsSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
View list on Hardcover
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<div className="relative z-10 mb-2 flex items-center sm:mb-3">
|
||||
{/* Left: Sort control or Hardcover link */}
|
||||
<div>
|
||||
{showSortControl && (
|
||||
<SortControl
|
||||
value={sortValue}
|
||||
onChange={onSortChange}
|
||||
metadataSortOptions={metadataSortOptions}
|
||||
/>
|
||||
)}
|
||||
{!showSortControl && resultsSourceUrl && (
|
||||
<a
|
||||
href={resultsSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
View list on Hardcover
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
|
||||
{/* Center: Results count */}
|
||||
<div className="flex flex-1 justify-center">
|
||||
{(() => {
|
||||
const count = directTotalResults ?? null;
|
||||
if (count === null || count === 0) return null;
|
||||
const isCapped = directTotalResults === '500+';
|
||||
const totalCount = isCapped ? 500 : Number(count);
|
||||
// AA-style: show page range + total
|
||||
if (totalCount === 1) {
|
||||
return (
|
||||
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
Result 1 (1 Total)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const shownEnd = Math.min(totalCount, 50);
|
||||
const totalStr = isCapped ? '500+' : String(totalCount);
|
||||
return (
|
||||
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
Results 1-{shownEnd} ({totalStr} Total)
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right: View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isDesktop && (
|
||||
<button
|
||||
|
||||
@@ -288,6 +288,7 @@ export function useReleaseSearchSession(
|
||||
[tabName]: {
|
||||
...existing,
|
||||
releases: [...existing.releases, ...mergedReleases],
|
||||
search_info: response.search_info,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ interface UseSearchReturn {
|
||||
isLoadingMore: boolean;
|
||||
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
|
||||
totalFound: number;
|
||||
// Direct mode: total result count from release sources (e.g., "500+" for capped)
|
||||
directTotalResults: number | string | null;
|
||||
// Source URL and title for the current result set (e.g. Hardcover list page)
|
||||
resultsSourceUrl: string | undefined;
|
||||
resultsSourceTitle: string | undefined;
|
||||
@@ -92,6 +94,8 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [totalFound, setTotalFound] = useState(0);
|
||||
// Direct mode: total result count from release sources (e.g., Anna's Archive)
|
||||
const [directTotalResults, setDirectTotalResults] = useState<number | string | null>(null);
|
||||
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
|
||||
const [resultsSourceTitle, setResultsSourceTitle] = useState<string | undefined>();
|
||||
|
||||
@@ -274,7 +278,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setLastSearchQuery(query);
|
||||
|
||||
try {
|
||||
const results = await searchBooks(query);
|
||||
const { books: results, totalResults } = await searchBooks(query);
|
||||
|
||||
if (results.length > 0) {
|
||||
// When no explicit server-side sort is selected (empty or "downloads"),
|
||||
@@ -284,8 +288,10 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const isDownloadsSort = !effectiveSort || effectiveSort === 'downloads';
|
||||
const sorted = isDownloadsSort ? sortBooksByDownloads(results) : results;
|
||||
setBooks(sorted);
|
||||
setDirectTotalResults(totalResults);
|
||||
} else {
|
||||
showToast('No results found', 'error');
|
||||
setDirectTotalResults(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
@@ -335,6 +341,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setDirectTotalResults(null);
|
||||
setResultsSourceUrl(undefined);
|
||||
setResultsSourceTitle(undefined);
|
||||
lastSearchParamsRef.current = null;
|
||||
@@ -412,6 +419,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
totalFound,
|
||||
directTotalResults,
|
||||
resultsSourceUrl,
|
||||
resultsSourceTitle,
|
||||
};
|
||||
|
||||
@@ -279,14 +279,20 @@ async function fetchJSON<T>(
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const searchBooks = async (query: string): Promise<Book[]> => {
|
||||
if (!query) return [];
|
||||
export const searchBooks = async (
|
||||
query: string,
|
||||
): Promise<{ books: Book[]; totalResults: number | string | null }> => {
|
||||
if (!query) return { books: [], totalResults: null };
|
||||
const response = await fetchJSON<ReleasesResponse>(
|
||||
`${API_BASE}/releases?source=direct_download&${query}`,
|
||||
{},
|
||||
searchTimeoutMs,
|
||||
);
|
||||
return response.releases.map(transformReleaseToDirectBook);
|
||||
// Extract total count from search_info if available
|
||||
const firstSource = response.sources_searched?.[0];
|
||||
const searchInfo = firstSource ? response.search_info?.[firstSource] : undefined;
|
||||
const totalResults = searchInfo?.total_results ?? null;
|
||||
return { books: response.releases.map(transformReleaseToDirectBook), totalResults };
|
||||
};
|
||||
|
||||
// Metadata search response type (internal)
|
||||
|
||||
@@ -475,6 +475,7 @@ export interface Release {
|
||||
// Search info returned by release sources
|
||||
export interface SourceSearchInfo {
|
||||
search_type: 'isbn' | 'title_author' | 'categories' | 'expanded' | 'manual' | 'query';
|
||||
total_results?: number | string | null;
|
||||
}
|
||||
|
||||
// Response from /api/releases endpoint
|
||||
|
||||
Reference in new issue
Block a user