mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a81549768c
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.
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
/**
|
|
* Component: Request Card
|
|
* Documentation: documentation/frontend/components.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React from 'react';
|
|
import Image from 'next/image';
|
|
import { StatusBadge } from './StatusBadge';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { useCancelRequest } from '@/lib/hooks/useRequests';
|
|
import { cn } from '@/lib/utils/cn';
|
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
|
|
|
interface RequestCardProps {
|
|
request: {
|
|
id: string;
|
|
type?: 'audiobook' | 'ebook';
|
|
status: string;
|
|
progress: number;
|
|
errorMessage?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
completedAt?: string;
|
|
downloadAvailable?: boolean;
|
|
audiobook: {
|
|
id: string;
|
|
audibleAsin?: string;
|
|
title: string;
|
|
author: string;
|
|
coverArtUrl?: string;
|
|
filePath?: string | null;
|
|
fileFormat?: string | null;
|
|
};
|
|
};
|
|
showActions?: boolean;
|
|
}
|
|
|
|
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|
const { cancelRequest, isLoading } = useCancelRequest();
|
|
const { squareCovers } = usePreferences();
|
|
const [showError, setShowError] = React.useState(false);
|
|
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
|
const [coverError, setCoverError] = React.useState(false);
|
|
|
|
const requestType = request.type || 'audiobook';
|
|
const isEbook = requestType === 'ebook';
|
|
|
|
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
|
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
|
const isFailed = request.status === 'failed';
|
|
|
|
const handleCancel = async () => {
|
|
if (window.confirm('Are you sure you want to cancel this request?')) {
|
|
try {
|
|
await cancelRequest(request.id);
|
|
} catch (error) {
|
|
console.error('Failed to cancel request:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
|
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
|
{/* Cover Art */}
|
|
<div className="flex-shrink-0">
|
|
<div
|
|
className={cn(
|
|
'relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700',
|
|
squareCovers
|
|
? 'w-16 sm:w-24 aspect-square'
|
|
: 'w-16 sm:w-24 aspect-[2/3]',
|
|
request.audiobook.audibleAsin && 'cursor-pointer hover:opacity-90 transition-opacity'
|
|
)}
|
|
onClick={() => request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
|
role={request.audiobook.audibleAsin ? 'button' : undefined}
|
|
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
|
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
|
>
|
|
{request.audiobook.coverArtUrl && !coverError ? (
|
|
<Image
|
|
src={request.audiobook.coverArtUrl}
|
|
alt={request.audiobook.title}
|
|
fill
|
|
className="object-cover"
|
|
sizes="96px"
|
|
onError={() => setCoverError(true)}
|
|
/>
|
|
) : isEbook ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<svg
|
|
className="w-12 h-12"
|
|
style={{ color: '#f16f19' }}
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
|
</svg>
|
|
</div>
|
|
) : (
|
|
<Image
|
|
src="/placeholder_cover.svg"
|
|
alt={request.audiobook.title}
|
|
fill
|
|
className="object-cover"
|
|
sizes="96px"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Request Info */}
|
|
<div className="flex-1 min-w-0 space-y-1.5 sm:space-y-2">
|
|
{/* Title and Author */}
|
|
<div>
|
|
<h3 className="text-sm sm:text-base md:text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-2">
|
|
{request.audiobook.title}
|
|
</h3>
|
|
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 truncate">
|
|
By {request.audiobook.author}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Status Badge and Type Badge */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<StatusBadge status={request.status} progress={request.progress} />
|
|
{isEbook && (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
|
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
|
>
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
|
</svg>
|
|
Ebook
|
|
</span>
|
|
)}
|
|
{isActive && request.progress > 0 && (
|
|
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<div className="animate-pulse w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<span>Active</span>
|
|
</div>
|
|
)}
|
|
{isActive && request.progress === 0 && (
|
|
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<div className="animate-spin w-3 h-3 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
|
|
<span>Setting up...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar (for downloading/processing) */}
|
|
{isActive && request.progress > 0 && (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
|
<span>Progress</span>
|
|
<span>{request.progress}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full transition-all duration-300',
|
|
request.status === 'downloading' ? 'bg-purple-600' : 'bg-orange-600'
|
|
)}
|
|
style={{ width: `${request.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{isFailed && request.errorMessage && (
|
|
<div className="space-y-1">
|
|
<button
|
|
onClick={() => setShowError(!showError)}
|
|
className="text-xs text-red-600 dark:text-red-400 hover:underline flex items-center gap-1"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d={showError ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'}
|
|
/>
|
|
</svg>
|
|
{showError ? 'Hide error' : 'Show error'}
|
|
</button>
|
|
{showError && (
|
|
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-2 rounded">
|
|
{request.errorMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Timestamps and Actions */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="text-xs text-gray-500 dark:text-gray-500">
|
|
{request.completedAt
|
|
? `Completed ${formatDate(request.completedAt)}`
|
|
: `Requested ${formatDate(request.createdAt)}`}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
{showActions && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{canCancel && (
|
|
<Button
|
|
onClick={handleCancel}
|
|
loading={isLoading}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Audiobook Details Modal */}
|
|
{request.audiobook.audibleAsin && (
|
|
<AudiobookDetailsModal
|
|
asin={request.audiobook.audibleAsin}
|
|
isOpen={showDetailsModal}
|
|
onClose={() => setShowDetailsModal(false)}
|
|
requestStatus={request.status}
|
|
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
|
|
hideRequestActions
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|