mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add custom search terms & retry download (admin)
Add support for per-request custom search terms and an admin retry-download flow. - DB/schema: add custom_search_terms column via Prisma migration and schema update. - Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions. - API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata. - Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms. - UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata. - Misc: add connection-errors util and update related processors/services and tests to cover the new flows. These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
|
||||
export interface Audiobook {
|
||||
@@ -57,20 +59,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearch(query: string, page: number = 1) {
|
||||
const shouldFetch = query && query.length > 0;
|
||||
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000, // Cache for 30 seconds
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const prevQueryRef = useRef(query);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!query || query.length === 0) return null;
|
||||
if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset to page 1 when query changes
|
||||
useEffect(() => {
|
||||
if (query !== prevQueryRef.current) {
|
||||
prevQueryRef.current = query;
|
||||
setSize(1);
|
||||
}
|
||||
}, [query, setSize]);
|
||||
|
||||
const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : [];
|
||||
const totalResults = data?.[0]?.totalResults || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!query;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
results: data?.results || [],
|
||||
totalResults: data?.totalResults || 0,
|
||||
hasMore: data?.hasMore || false,
|
||||
isLoading: shouldFetch && isLoading,
|
||||
results,
|
||||
totalResults,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
+52
-12
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const shouldFetch = asin && authorName;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000, // Cache for 1 minute
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const prevIdentityRef = useRef<string | null>(null);
|
||||
const identity = asin && authorName ? `${asin}:${authorName}` : null;
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin || !authorName) return null;
|
||||
if (pageIndex === 0) return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when author changes
|
||||
useEffect(() => {
|
||||
if (identity !== prevIdentityRef.current) {
|
||||
prevIdentityRef.current = identity;
|
||||
setSize(1);
|
||||
}
|
||||
}, [identity, setSize]);
|
||||
|
||||
const books = (data ? dedupeByAsin(data.flatMap(page => page?.books || [])) : []) as Audiobook[];
|
||||
const totalBooks = data?.[0]?.totalBooks || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!identity;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
books: (data?.books || []) as Audiobook[],
|
||||
totalBooks: data?.totalBooks || 0,
|
||||
isLoading: !!shouldFetch && isLoading,
|
||||
books,
|
||||
totalBooks,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!identity && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
@@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSeriesDetail(asin: string | null) {
|
||||
const endpoint = asin ? `/api/series/${asin}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000, // Cache for 5 minutes
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSeriesDetail(asin: string | null) {
|
||||
const prevAsinRef = useRef<string | null>(null);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin) return null;
|
||||
if (pageIndex === 0) return `/api/series/${asin}?page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/series/${asin}?page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when series changes
|
||||
useEffect(() => {
|
||||
if (asin !== prevAsinRef.current) {
|
||||
prevAsinRef.current = asin;
|
||||
setSize(1);
|
||||
}
|
||||
}, [asin, setSize]);
|
||||
|
||||
// Merge pages: use first page's metadata, accumulate all books
|
||||
const firstPageSeries = data?.[0]?.series as SeriesDetail | undefined;
|
||||
const allBooks = (data ? dedupeByAsin(data.flatMap(page => page?.series?.books || [])) : []) as Audiobook[];
|
||||
|
||||
const series: SeriesDetail | null = firstPageSeries
|
||||
? { ...firstPageSeries, books: allBooks }
|
||||
: null;
|
||||
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!asin;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
series: (data?.series || null) as SeriesDetail | null,
|
||||
isLoading,
|
||||
series,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!asin && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user