mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 12:20:09 +00:00
Add paginated requests API and My Requests UI
Introduce cursor-based pagination and group counts for /api/requests (status groups, nextCursor, counts) and fetch one extra record to detect next page. Add a client-side My Requests experience: useSWRInfinite hook (useMyRequests) with smart polling for active requests, tabbed filters, badges, skeletons, load-more, and animated list entries. Update RequestCard and admin actions to treat awaiting_search as cancellable. Adjust Plex processors to ignore requests with status 'denied' when matching new media. Add static ffmpeg in the Docker image and remove preinstalled ImageMagick to avoid transitive deps. Update tests to account for pagination/take+1 and the new hook/UX behavior.
This commit is contained in:
+13
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
||||
supervisor \
|
||||
curl \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
locales \
|
||||
gosu \
|
||||
xz-utils \
|
||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install static ffmpeg (no transitive dependencies like imagemagick)
|
||||
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
|
||||
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
|
||||
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf /tmp/ffmpeg*
|
||||
|
||||
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
|
||||
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
|
||||
'libmagickcore*' 'libmagickwand*' && \
|
||||
apt-get autoremove -y --purge && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RequestActionsDropdown({
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
|
||||
@@ -97,9 +97,27 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Status groups for server-side filtering and count aggregation
|
||||
const STATUS_GROUPS: Record<string, string[]> = {
|
||||
active: ['pending', 'searching', 'downloading', 'processing'],
|
||||
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
|
||||
completed: ['available', 'downloaded'],
|
||||
failed: ['failed'],
|
||||
cancelled: ['cancelled', 'denied'],
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/requests?status=pending&limit=50
|
||||
* Get user's audiobook requests (or all requests for admins)
|
||||
* GET /api/requests
|
||||
* Get user's audiobook requests with cursor-based pagination and accurate counts.
|
||||
*
|
||||
* Query params:
|
||||
* status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status
|
||||
* cursor - request ID for cursor-based pagination (exclusive start)
|
||||
* take - page size (default 20, max 100)
|
||||
* myOnly - 'true' to restrict to current user even for admins
|
||||
* type - 'audiobook'|'ebook'
|
||||
*
|
||||
* Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } }
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
@@ -112,61 +130,102 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const status = searchParams.get('status');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const statusParam = searchParams.get('status');
|
||||
const cursor = searchParams.get('cursor');
|
||||
const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100);
|
||||
// Legacy support: honour `limit` if `take` not supplied
|
||||
const limit = searchParams.has('take')
|
||||
? take
|
||||
: Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
|
||||
const myOnly = searchParams.get('myOnly') === 'true';
|
||||
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
|
||||
const type = searchParams.get('type');
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Build query
|
||||
// If myOnly=true, always filter by current user (even for admins)
|
||||
// Otherwise, admins see all requests, users see only their own
|
||||
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
// Filter by type if specified (otherwise returns all types)
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
where.type = type;
|
||||
}
|
||||
// Only show active (non-deleted) requests
|
||||
where.deletedAt = null;
|
||||
// Base ownership filter
|
||||
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
|
||||
baseWhere.deletedAt = null;
|
||||
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
baseWhere.type = type;
|
||||
}
|
||||
|
||||
// Resolve status filter
|
||||
const statusFilter: any = {};
|
||||
if (statusParam) {
|
||||
const group = STATUS_GROUPS[statusParam];
|
||||
if (group) {
|
||||
statusFilter.status = { in: group };
|
||||
} else {
|
||||
// Treat as a specific status literal
|
||||
statusFilter.status = statusParam;
|
||||
}
|
||||
}
|
||||
|
||||
const where = { ...baseWhere, ...statusFilter };
|
||||
|
||||
// ── Paginated request fetch ──────────────────────────────────────────
|
||||
const requests = await prisma.request.findMany({
|
||||
where,
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
take: limit + 1, // fetch one extra to determine if there's a next page
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const enriched = requests.map(r => {
|
||||
const hasNextPage = requests.length > limit;
|
||||
const page = hasNextPage ? requests.slice(0, limit) : requests;
|
||||
const nextCursor = hasNextPage ? page[page.length - 1].id : null;
|
||||
|
||||
const enriched = page.map(r => {
|
||||
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
||||
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
|
||||
// Strip server-side absolute path from client response
|
||||
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
||||
return { ...r, audiobook, downloadAvailable };
|
||||
});
|
||||
|
||||
// ── Accurate counts per group (always scoped to ownership/type filter) ──
|
||||
const countWhere = { ...baseWhere };
|
||||
|
||||
const [
|
||||
totalAll,
|
||||
totalActive,
|
||||
totalWaiting,
|
||||
totalCompleted,
|
||||
totalFailed,
|
||||
totalCancelled,
|
||||
] = await Promise.all([
|
||||
prisma.request.count({ where: countWhere }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }),
|
||||
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requests: enriched,
|
||||
nextCursor,
|
||||
counts: {
|
||||
all: totalAll,
|
||||
active: totalActive,
|
||||
waiting: totalWaiting,
|
||||
completed: totalCompleted,
|
||||
failed: totalFailed,
|
||||
cancelled: totalCancelled,
|
||||
},
|
||||
// Legacy field for callers that still read `count`
|
||||
count: enriched.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch requests',
|
||||
},
|
||||
{ error: 'FetchError', message: 'Failed to fetch requests' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +197,23 @@ body {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Requests page list entry animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
@keyframes dialog-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
|
||||
+344
-160
@@ -1,221 +1,405 @@
|
||||
/**
|
||||
* Component: Requests Page
|
||||
* Component: My Requests Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { RequestCard } from '@/components/requests/RequestCard';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { useMyRequests, RequestFilterGroup, RequestCounts } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
||||
// ── Tab configuration ────────────────────────────────────────────────────────
|
||||
|
||||
interface TabOption {
|
||||
value: RequestFilterGroup;
|
||||
label: string;
|
||||
countKey: keyof RequestCounts;
|
||||
}
|
||||
|
||||
const TABS: TabOption[] = [
|
||||
{ value: 'all', label: 'All', countKey: 'all' },
|
||||
{ value: 'active', label: 'Active', countKey: 'active' },
|
||||
{ value: 'waiting', label: 'Waiting', countKey: 'waiting' },
|
||||
{ value: 'completed', label: 'Completed', countKey: 'completed' },
|
||||
{ value: 'failed', label: 'Failed', countKey: 'failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelled' },
|
||||
];
|
||||
|
||||
// ── Count badge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CountBadge({ count, active }: { count: number; active: boolean }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-semibold tabular-nums transition-all duration-200',
|
||||
active
|
||||
? 'bg-blue-500/20 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400'
|
||||
: 'bg-gray-200/80 text-gray-500 dark:bg-white/[0.07] dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{count > 999 ? '999+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton card ────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800/60 rounded-xl overflow-hidden border border-gray-100 dark:border-white/[0.06]">
|
||||
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{/* Cover placeholder */}
|
||||
<div className="flex-shrink-0 w-16 sm:w-24 aspect-[2/3] rounded-lg bg-gray-200 dark:bg-white/[0.06] animate-pulse" />
|
||||
{/* Content placeholder */}
|
||||
<div className="flex-1 min-w-0 space-y-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-1/2" />
|
||||
</div>
|
||||
<div className="h-5 bg-gray-200 dark:bg-white/[0.06] rounded-full animate-pulse w-20" />
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-white/[0.05]">
|
||||
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded animate-pulse w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ filter }: { filter: RequestFilterGroup }) {
|
||||
const isAll = filter === 'all';
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{isAll ? 'No requests yet' : `No ${filter} requests`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
|
||||
{isAll
|
||||
? 'Start by searching for audiobooks and requesting them'
|
||||
: `You don't have any ${filter} requests right now`}
|
||||
</p>
|
||||
</div>
|
||||
{isAll && (
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium rounded-xl transition-all duration-150 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Browse Audiobooks
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Load More button ─────────────────────────────────────────────────────────
|
||||
|
||||
function LoadMoreButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-center pt-2 pb-4">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||
'border border-gray-200 dark:border-white/[0.1]',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'bg-white dark:bg-white/[0.04]',
|
||||
'hover:bg-gray-50 dark:hover:bg-white/[0.07]',
|
||||
'active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
'shadow-sm'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading more...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Load more
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Live indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
function LiveIndicator({ hasActive }: { hasActive: boolean }) {
|
||||
if (!hasActive) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
|
||||
</span>
|
||||
Live
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabBarProps {
|
||||
filter: RequestFilterGroup;
|
||||
counts: RequestCounts;
|
||||
countsLoaded: boolean;
|
||||
onChange: (f: RequestFilterGroup) => void;
|
||||
}
|
||||
|
||||
function TabBar({ filter, counts, countsLoaded, onChange }: TabBarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll active tab into view on mount/change
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const active = container.querySelector('[data-active="true"]') as HTMLElement | null;
|
||||
if (active) {
|
||||
const { offsetLeft, offsetWidth } = active;
|
||||
const { scrollLeft, clientWidth } = container;
|
||||
if (offsetLeft < scrollLeft || offsetLeft + offsetWidth > scrollLeft + clientWidth) {
|
||||
container.scrollTo({ left: offsetLeft - 16, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className="relative -mx-4 sm:mx-0">
|
||||
{/* Left fade */}
|
||||
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||
{/* Right fade */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-1 overflow-x-auto scrollbar-hide px-4 sm:px-0"
|
||||
role="tablist"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = filter === tab.value;
|
||||
const count = counts[tab.countKey];
|
||||
// Hide tabs with 0 count unless it's 'all' or currently active
|
||||
if (!isActive && tab.value !== 'all' && countsLoaded && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none flex-shrink-0',
|
||||
'focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/[0.08] text-gray-900 dark:text-white shadow-[0_1px_3px_rgba(0,0,0,0.08),0_1px_6px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.4)] border border-gray-200/80 dark:border-white/[0.1]'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{countsLoaded
|
||||
? <CountBadge count={count} active={isActive} />
|
||||
: tab.value !== 'all' && (
|
||||
<span className="inline-block w-5 h-3.5 rounded bg-gray-200 dark:bg-white/[0.07] animate-pulse" />
|
||||
)
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Showing count bar ────────────────────────────────────────────────────────
|
||||
|
||||
function ShowingBar({ showing, total, hasActive }: { showing: number; total: number; hasActive: boolean }) {
|
||||
if (showing === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 dark:text-gray-500 px-0.5">
|
||||
<span>
|
||||
Showing <span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{showing}</span>
|
||||
{' of '}
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{total}</span>
|
||||
{total === 1 ? ' request' : ' requests'}
|
||||
</span>
|
||||
<LiveIndicator hasActive={hasActive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [filter, setFilter] = useState<FilterStatus>('all');
|
||||
const [filter, setFilter] = useState<RequestFilterGroup>('all');
|
||||
|
||||
// Always fetch only the current user's requests (even for admins)
|
||||
// This ensures "My Requests" truly shows only the user's own requests
|
||||
// Admins can see all requests in the admin panel
|
||||
const { requests, isLoading } = useRequests(undefined, 50, true);
|
||||
const {
|
||||
requests,
|
||||
counts,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isEmpty,
|
||||
loadMore,
|
||||
} = useMyRequests(filter);
|
||||
|
||||
// Filter requests client-side based on selected filter
|
||||
const filteredRequests = filter === 'all'
|
||||
? requests
|
||||
: filter === 'active'
|
||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status))
|
||||
: filter === 'waiting'
|
||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status))
|
||||
: filter === 'completed'
|
||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status))
|
||||
: requests.filter((r: any) => r.status === filter);
|
||||
const countsLoaded = !isLoading || requests.length > 0;
|
||||
const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
|
||||
const hasActiveRequests = requests.some(r =>
|
||||
['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
|
||||
);
|
||||
|
||||
const filterOptions: { value: FilterStatus; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'waiting', label: 'Waiting' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
const handleFilterChange = (f: RequestFilterGroup) => {
|
||||
setFilter(f);
|
||||
};
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// ── Unauthenticated ────────────────────────────────────────────────────────
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please log in to view your audiobook requests
|
||||
</p>
|
||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Authenticated ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="space-y-2 sm:space-y-4">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
|
||||
My Requests
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Track the status of your audiobook requests in real-time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={cn(
|
||||
'px-3 sm:px-4 py-2 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
||||
filter === option.value
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{!isLoading && (
|
||||
<span className="ml-2 text-xs">
|
||||
({option.value === 'all'
|
||||
? requests.length
|
||||
: option.value === 'active'
|
||||
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length
|
||||
: option.value === 'waiting'
|
||||
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length
|
||||
: option.value === 'completed'
|
||||
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length
|
||||
: requests.filter((r: any) => r.status === option.value).length
|
||||
})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="mb-5">
|
||||
<TabBar
|
||||
filter={filter}
|
||||
counts={counts}
|
||||
countsLoaded={countsLoaded}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{/* Showing bar */}
|
||||
{!isLoading && requests.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<ShowingBar
|
||||
showing={requests.length}
|
||||
total={totalForFilter}
|
||||
hasActive={hasActiveRequests}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state — skeleton cards */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
className="animate-[fadeIn_0.3s_ease-out_both]"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className={cn(
|
||||
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
|
||||
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
|
||||
)}></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requests List */}
|
||||
{!isLoading && filteredRequests.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{filteredRequests.map((request: any) => (
|
||||
<RequestCard key={request.id} request={request} showActions={true} />
|
||||
{/* Request list */}
|
||||
{!isLoading && requests.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{requests.map((request, i) => (
|
||||
<div
|
||||
key={request.id}
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}
|
||||
className="animate-[fadeInUp_0.25s_ease-out_both]"
|
||||
>
|
||||
<RequestCard request={request} showActions={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredRequests.length === 0 && (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{filter === 'all' ? 'No requests yet' : `No ${filter} requests`}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{filter === 'all'
|
||||
? 'Start by searching for audiobooks and requesting them'
|
||||
: `You don't have any ${filter} requests at the moment`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{filter === 'all' && (
|
||||
<div className="pt-4">
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* Empty state */}
|
||||
{!isLoading && isEmpty && (
|
||||
<EmptyState filter={filter} />
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!isLoading && hasMore && (
|
||||
<div className="mt-4">
|
||||
<LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-refresh indicator */}
|
||||
{!isLoading && filteredRequests.length > 0 && (
|
||||
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Auto-refreshing every 5 seconds</span>
|
||||
</div>
|
||||
{/* Load more skeleton (when fetching additional pages) */}
|
||||
{isLoadingMore && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<SkeletonCard key={`more-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
@@ -59,6 +60,95 @@ export function useRequests(status?: string, limit: number = 50, myOnly: boolean
|
||||
};
|
||||
}
|
||||
|
||||
// ── Active statuses that warrant live polling ────────────────────────────────
|
||||
const ACTIVE_STATUSES = new Set([
|
||||
'pending', 'awaiting_search', 'awaiting_approval',
|
||||
'searching', 'downloading', 'processing', 'awaiting_import',
|
||||
]);
|
||||
|
||||
export type RequestFilterGroup = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface RequestCounts {
|
||||
all: number;
|
||||
active: number;
|
||||
waiting: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
export interface RequestPage {
|
||||
requests: Request[];
|
||||
nextCursor: string | null;
|
||||
counts: RequestCounts;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Paginated hook for "My Requests" page.
|
||||
* Uses SWRInfinite for cursor-based pagination.
|
||||
* Polls only when active requests are present.
|
||||
*/
|
||||
export function useMyRequests(filter: RequestFilterGroup) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const getKey = (pageIndex: number, previousPage: RequestPage | null): string | null => {
|
||||
if (!accessToken) return null;
|
||||
if (previousPage && !previousPage.nextCursor) return null; // reached end
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('myOnly', 'true');
|
||||
params.set('take', String(PAGE_SIZE));
|
||||
if (filter !== 'all') params.set('status', filter);
|
||||
if (pageIndex > 0 && previousPage?.nextCursor) {
|
||||
params.set('cursor', previousPage.nextCursor);
|
||||
}
|
||||
return `/api/requests?${params.toString()}`;
|
||||
};
|
||||
|
||||
const { data, error, isLoading, isValidating, size, setSize, mutate: revalidate } =
|
||||
useSWRInfinite<RequestPage>(getKey, fetcher, {
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: false,
|
||||
// Smart polling: refresh every 5s only when active requests exist
|
||||
refreshInterval: (data) => {
|
||||
if (!data) return 5000;
|
||||
const hasActive = data.some(page =>
|
||||
page.requests.some(r => ACTIVE_STATUSES.has(r.status))
|
||||
);
|
||||
return hasActive ? 5000 : 0;
|
||||
},
|
||||
});
|
||||
|
||||
const allRequests = useMemo(
|
||||
() => data?.flatMap(page => page.requests) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
// Counts come from the first page (always the authoritative totals)
|
||||
const counts: RequestCounts = data?.[0]?.counts ?? {
|
||||
all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0,
|
||||
};
|
||||
|
||||
const hasMore = data ? !!data[data.length - 1]?.nextCursor : false;
|
||||
const isLoadingMore = isValidating && size > 1 && !data?.[size - 1];
|
||||
const isEmpty = !isLoading && allRequests.length === 0;
|
||||
|
||||
const loadMore = () => setSize(s => s + 1);
|
||||
|
||||
return {
|
||||
requests: allRequests,
|
||||
counts,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isEmpty,
|
||||
loadMore,
|
||||
revalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRequest(requestId: string) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
status: { notIn: ['available', 'cancelled', 'denied'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -439,7 +439,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
status: { notIn: ['available', 'cancelled', 'denied'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -202,6 +202,7 @@ describe('Requests API routes', () => {
|
||||
it('filters requests for current user when not admin', async () => {
|
||||
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.request.count.mockResolvedValue(0);
|
||||
|
||||
const { GET } = await import('@/app/api/requests/route');
|
||||
const response = await GET({} as any);
|
||||
@@ -212,7 +213,7 @@ describe('Requests API routes', () => {
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
|
||||
take: 5,
|
||||
take: 6, // limit + 1 for cursor pagination next-page detection
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,10 +11,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const useMyRequestsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useRequests: useRequestsMock,
|
||||
useMyRequests: useMyRequestsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
@@ -41,13 +41,18 @@ describe('RequestsPage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useRequestsMock.mockReset();
|
||||
useMyRequestsMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const defaultCounts = { all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0 };
|
||||
|
||||
it('prompts for authentication when no user is available', async () => {
|
||||
setMockAuthState({ user: null });
|
||||
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
|
||||
useMyRequestsMock.mockReturnValue({
|
||||
requests: [], counts: defaultCounts, hasMore: false,
|
||||
isLoading: false, isLoadingMore: false, isEmpty: true, loadMore: vi.fn(),
|
||||
});
|
||||
|
||||
const { default: RequestsPage } = await import('@/app/requests/page');
|
||||
render(<RequestsPage />);
|
||||
@@ -62,23 +67,35 @@ describe('RequestsPage', () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const requests = [
|
||||
const allRequests = [
|
||||
{ id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } },
|
||||
{ id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', author: 'Author' } },
|
||||
{ id: 'req-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } },
|
||||
{ id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', author: 'Author' } },
|
||||
];
|
||||
|
||||
useRequestsMock.mockReturnValue({ requests, isLoading: false });
|
||||
const counts = { all: 4, active: 1, waiting: 1, completed: 1, failed: 1, cancelled: 0 };
|
||||
|
||||
// The hook is called with the current filter; mock returns different data per filter
|
||||
useMyRequestsMock.mockImplementation((filter: string) => {
|
||||
let requests = allRequests;
|
||||
if (filter === 'active') requests = allRequests.filter(r => r.status === 'pending');
|
||||
else if (filter === 'waiting') requests = allRequests.filter(r => r.status === 'awaiting_search');
|
||||
return {
|
||||
requests, counts, hasMore: false,
|
||||
isLoading: false, isLoadingMore: false, isEmpty: requests.length === 0, loadMore: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { default: RequestsPage } = await import('@/app/requests/page');
|
||||
render(<RequestsPage />);
|
||||
|
||||
const activeTab = screen.getByRole('button', { name: /Active/i });
|
||||
const waitingTab = screen.getByRole('button', { name: /Waiting/i });
|
||||
// Counts now render as badge numbers inside tabs, not "(1)" format
|
||||
const activeTab = screen.getByRole('tab', { name: /Active/i });
|
||||
const waitingTab = screen.getByRole('tab', { name: /Waiting/i });
|
||||
|
||||
expect(activeTab).toHaveTextContent('(1)');
|
||||
expect(waitingTab).toHaveTextContent('(1)');
|
||||
expect(activeTab).toHaveTextContent('1');
|
||||
expect(waitingTab).toHaveTextContent('1');
|
||||
|
||||
fireEvent.click(activeTab);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user