mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 21:30:11 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Component: Interactive Torrent Search Modal
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { useInteractiveSearch, useSelectTorrent } from '@/lib/hooks/useRequests';
|
||||
|
||||
interface InteractiveTorrentSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function InteractiveTorrentSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
audiobook,
|
||||
}: InteractiveTorrentSearchModalProps) {
|
||||
const { searchTorrents, isLoading: isSearching, error: searchError } = useInteractiveSearch();
|
||||
const { selectTorrent, isLoading: isDownloading, error: downloadError } = useSelectTorrent();
|
||||
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
|
||||
const error = searchError || downloadError;
|
||||
|
||||
// Perform search when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen && results.length === 0) {
|
||||
performSearch();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const performSearch = async () => {
|
||||
try {
|
||||
const data = await searchTorrents(requestId);
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
// Error already handled by hook
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadClick = (torrent: TorrentResult) => {
|
||||
setConfirmTorrent(torrent);
|
||||
};
|
||||
|
||||
const handleConfirmDownload = async () => {
|
||||
if (!confirmTorrent) return;
|
||||
|
||||
try {
|
||||
await selectTorrent(requestId, confirmTorrent);
|
||||
// Close modals on success
|
||||
setConfirmTorrent(null);
|
||||
onClose();
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (err) {
|
||||
// Error already handled by hook
|
||||
console.error('Failed to download torrent:', err);
|
||||
setConfirmTorrent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const getQualityBadgeColor = (score: number) => {
|
||||
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
|
||||
<div className="space-y-4">
|
||||
{/* Audiobook info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{audiobook.title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">By {audiobook.author}</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
|
||||
<span className="ml-3 text-gray-600 dark:text-gray-400">Searching for torrents...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isSearching && results.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No torrents found</p>
|
||||
<Button onClick={performSearch} variant="outline" className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-6">
|
||||
<div className="inline-block min-w-full align-middle px-6">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
#
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Score
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell">
|
||||
Seeds
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell">
|
||||
Indexer
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{results.map((result) => (
|
||||
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{result.rank}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="max-w-xs lg:max-w-md truncate" title={result.title}>
|
||||
{result.title}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
{result.format && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
|
||||
{result.format}
|
||||
</span>
|
||||
)}
|
||||
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
{formatSize(result.size)}
|
||||
</span>
|
||||
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
|
||||
{result.seeders} seeds
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
|
||||
{formatSize(result.size)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(result.qualityScore || 0)}`}>
|
||||
{result.qualityScore || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{result.seeders}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
|
||||
{result.indexer}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
||||
<Button
|
||||
onClick={() => handleDownloadClick(result)}
|
||||
disabled={isDownloading}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with result count */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<Button onClick={performSearch} variant="outline" size="sm">
|
||||
Refresh Results
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!confirmTorrent}
|
||||
onClose={() => setConfirmTorrent(null)}
|
||||
onConfirm={handleConfirmDownload}
|
||||
title="Download Torrent"
|
||||
message={`Download "${confirmTorrent?.title}"?`}
|
||||
confirmText="Download"
|
||||
isLoading={isDownloading}
|
||||
variant="primary"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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, useManualSearch } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
};
|
||||
};
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const { cancelRequest, isLoading } = useCancelRequest();
|
||||
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
||||
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
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 handleManualSearch = async () => {
|
||||
try {
|
||||
await triggerManualSearch(request.id);
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger manual search:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to trigger manual search');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
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="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<Image
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={request.status} progress={request.progress} />
|
||||
{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">
|
||||
{canSearch && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleManualSearch}
|
||||
loading={isManualSearching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Manual Search
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInteractiveSearch}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Interactive Search
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
requestId={request.id}
|
||||
audiobook={{
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Component: Status Badge
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
},
|
||||
awaiting_search: {
|
||||
label: 'Awaiting Search',
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
},
|
||||
searching: {
|
||||
label: 'Searching...',
|
||||
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
},
|
||||
downloading: {
|
||||
label: progress !== undefined && progress === 0 ? 'Initializing...' : 'Downloading',
|
||||
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
},
|
||||
downloaded: {
|
||||
label: 'Downloaded',
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
processing: {
|
||||
label: 'Processing',
|
||||
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
},
|
||||
awaiting_import: {
|
||||
label: 'Awaiting Import',
|
||||
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
},
|
||||
available: {
|
||||
label: 'Available',
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
completed: {
|
||||
label: 'Available',
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
},
|
||||
warn: {
|
||||
label: 'Warning',
|
||||
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || {
|
||||
label: status,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user