Spaces:
Sleeping
Sleeping
| 'use client' | |
| import React, { useState, useEffect } from 'react' | |
| import { SourceListResponse } from '@/lib/types/api' | |
| import { Badge } from '@/components/ui/badge' | |
| import { Card, CardContent } from '@/components/ui/card' | |
| import { Button } from '@/components/ui/button' | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| DropdownMenuSeparator | |
| } from '@/components/ui/dropdown-menu' | |
| import { | |
| FileText, | |
| ExternalLink, | |
| Upload, | |
| MoreVertical, | |
| Trash2, | |
| RefreshCw, | |
| Clock, | |
| CheckCircle, | |
| AlertTriangle, | |
| Loader2, | |
| Unlink | |
| } from 'lucide-react' | |
| import { useSourceStatus } from '@/lib/hooks/use-sources' | |
| import { cn } from '@/lib/utils' | |
| import { ContextToggle } from '@/components/common/ContextToggle' | |
| import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page' | |
| interface SourceCardProps { | |
| source: SourceListResponse | |
| onDelete?: (sourceId: string) => void | |
| onRetry?: (sourceId: string) => void | |
| onRemoveFromNotebook?: (sourceId: string) => void | |
| onClick?: (sourceId: string) => void | |
| onRefresh?: () => void | |
| className?: string | |
| showRemoveFromNotebook?: boolean | |
| contextMode?: ContextMode | |
| onContextModeChange?: (mode: ContextMode) => void | |
| } | |
| const SOURCE_TYPE_ICONS = { | |
| link: ExternalLink, | |
| upload: Upload, | |
| text: FileText, | |
| } as const | |
| const STATUS_CONFIG = { | |
| new: { | |
| icon: Clock, | |
| color: 'text-blue-600', | |
| bgColor: 'bg-blue-50', | |
| borderColor: 'border-blue-200', | |
| label: 'Processing', | |
| description: 'Preparing to process' | |
| }, | |
| queued: { | |
| icon: Clock, | |
| color: 'text-blue-600', | |
| bgColor: 'bg-blue-50', | |
| borderColor: 'border-blue-200', | |
| label: 'Queued', | |
| description: 'Waiting to be processed' | |
| }, | |
| running: { | |
| icon: Loader2, | |
| color: 'text-blue-600', | |
| bgColor: 'bg-blue-50', | |
| borderColor: 'border-blue-200', | |
| label: 'Processing', | |
| description: 'Being processed' | |
| }, | |
| completed: { | |
| icon: CheckCircle, | |
| color: 'text-green-600', | |
| bgColor: 'bg-green-50', | |
| borderColor: 'border-green-200', | |
| label: 'Completed', | |
| description: 'Successfully processed' | |
| }, | |
| failed: { | |
| icon: AlertTriangle, | |
| color: 'text-red-600', | |
| bgColor: 'bg-red-50', | |
| borderColor: 'border-red-200', | |
| label: 'Failed', | |
| description: 'Processing failed' | |
| } | |
| } as const | |
| type SourceStatus = keyof typeof STATUS_CONFIG | |
| function isSourceStatus(status: unknown): status is SourceStatus { | |
| return typeof status === 'string' && status in STATUS_CONFIG | |
| } | |
| function getSourceType(source: SourceListResponse): 'link' | 'upload' | 'text' { | |
| // Determine type based on asset information | |
| if (source.asset?.url) return 'link' | |
| if (source.asset?.file_path) return 'upload' | |
| return 'text' | |
| } | |
| export function SourceCard({ | |
| source, | |
| onDelete, | |
| onRetry, | |
| onRemoveFromNotebook, | |
| onClick, | |
| onRefresh, | |
| className, | |
| showRemoveFromNotebook = false, | |
| contextMode, | |
| onContextModeChange | |
| }: SourceCardProps) { | |
| // Only fetch status for sources that might have async processing | |
| const sourceWithStatus = source as SourceListResponse & { command_id?: string; status?: string } | |
| // Track processing state to continue polling until we detect completion | |
| const [wasProcessing, setWasProcessing] = useState(false) | |
| const shouldFetchStatus = !!sourceWithStatus.command_id || | |
| sourceWithStatus.status === 'new' || | |
| sourceWithStatus.status === 'queued' || | |
| sourceWithStatus.status === 'running' || | |
| wasProcessing // Keep polling if we were processing to catch the completion | |
| const { data: statusData, isLoading: statusLoading } = useSourceStatus( | |
| source.id, | |
| shouldFetchStatus | |
| ) | |
| // Determine current status | |
| // If source has a command_id but no status, treat as "new" (just created) | |
| const rawStatus = statusData?.status || sourceWithStatus.status | |
| const currentStatus: SourceStatus = isSourceStatus(rawStatus) | |
| ? rawStatus | |
| : (sourceWithStatus.command_id ? 'new' : 'completed') | |
| // Track processing state and detect completion | |
| useEffect(() => { | |
| const currentStatusFromData = statusData?.status || sourceWithStatus.status | |
| // If we're currently processing, mark that we were processing | |
| if (currentStatusFromData === 'new' || currentStatusFromData === 'running' || currentStatusFromData === 'queued') { | |
| setWasProcessing(true) | |
| } | |
| // If we were processing and now completed/failed, trigger refresh and stop polling | |
| if (wasProcessing && | |
| (currentStatusFromData === 'completed' || currentStatusFromData === 'failed')) { | |
| setWasProcessing(false) // Stop polling | |
| if (onRefresh) { | |
| setTimeout(() => onRefresh(), 500) // Small delay to ensure API is updated | |
| } | |
| } | |
| }, [statusData, sourceWithStatus.status, wasProcessing, onRefresh, source.id]) | |
| const statusConfig = STATUS_CONFIG[currentStatus] || STATUS_CONFIG.completed | |
| const StatusIcon = statusConfig.icon | |
| const sourceType = getSourceType(source) | |
| const SourceTypeIcon = SOURCE_TYPE_ICONS[sourceType] | |
| const title = source.title || 'Untitled Source' | |
| const handleRetry = () => { | |
| if (onRetry) { | |
| onRetry(source.id) | |
| } | |
| } | |
| const handleDelete = () => { | |
| if (onDelete) { | |
| onDelete(source.id) | |
| } | |
| } | |
| const handleRemoveFromNotebook = () => { | |
| if (onRemoveFromNotebook) { | |
| onRemoveFromNotebook(source.id) | |
| } | |
| } | |
| const handleCardClick = () => { | |
| if (onClick) { | |
| onClick(source.id) | |
| } | |
| } | |
| const isProcessing: boolean = currentStatus === 'new' || currentStatus === 'running' || currentStatus === 'queued' | |
| const isFailed: boolean = currentStatus === 'failed' | |
| const isCompleted: boolean = currentStatus === 'completed' | |
| return ( | |
| <Card | |
| className={cn( | |
| 'transition-all duration-200 hover:shadow-md group relative cursor-pointer border border-border/60 dark:border-border/40', | |
| className | |
| )} | |
| onClick={handleCardClick} | |
| > | |
| <CardContent className="px-3 py-1"> | |
| {/* Header with status indicator */} | |
| <div className="flex items-start justify-between gap-3 mb-1"> | |
| <div className="flex-1 min-w-0"> | |
| {/* Status badge - only show if not completed */} | |
| {!isCompleted && ( | |
| <div className="flex items-center gap-2 mb-2"> | |
| <div className={cn( | |
| 'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium', | |
| statusConfig.bgColor, | |
| statusConfig.color | |
| )}> | |
| <StatusIcon className={cn( | |
| 'h-3 w-3', | |
| isProcessing && 'animate-spin' | |
| )} /> | |
| {statusLoading && shouldFetchStatus ? 'Checking...' : statusConfig.label} | |
| </div> | |
| {/* Source type indicator */} | |
| <div className="flex items-center gap-1 text-gray-500"> | |
| <SourceTypeIcon className="h-3 w-3" /> | |
| <span className="text-xs capitalize">{sourceType}</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Title */} | |
| <div className={cn('mb-1.5', !isCompleted && 'mb-1')}> | |
| <h4 | |
| className="text-sm font-medium leading-tight line-clamp-2" | |
| title={title} | |
| > | |
| {title} | |
| </h4> | |
| </div> | |
| {/* Processing message for active statuses */} | |
| {statusData?.message && (isProcessing || isFailed) && ( | |
| <p className="text-xs text-gray-600 mb-2 italic"> | |
| {statusData.message} | |
| </p> | |
| )} | |
| {/* Metadata badges */} | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| {/* Source type badge */} | |
| <Badge variant="secondary" className="text-xs flex items-center gap-1"> | |
| <SourceTypeIcon className="h-3 w-3" /> | |
| {sourceType} | |
| </Badge> | |
| {isCompleted && source.insights_count > 0 && ( | |
| <Badge variant="outline" className="text-xs"> | |
| {source.insights_count} insights | |
| </Badge> | |
| )} | |
| {source.topics && source.topics.length > 0 && isCompleted && ( | |
| <> | |
| {source.topics.slice(0, 2).map((topic, index) => ( | |
| <Badge key={index} variant="outline" className="text-xs"> | |
| {topic} | |
| </Badge> | |
| ))} | |
| {source.topics.length > 2 && ( | |
| <Badge variant="outline" className="text-xs"> | |
| +{source.topics.length - 2} | |
| </Badge> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Context toggle and actions */} | |
| <div className="flex items-center gap-1"> | |
| {/* Context toggle - only show if handler provided */} | |
| {onContextModeChange && contextMode && ( | |
| <ContextToggle | |
| mode={contextMode} | |
| hasInsights={source.insights_count > 0} | |
| onChange={onContextModeChange} | |
| /> | |
| )} | |
| {/* Actions dropdown */} | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <MoreVertical className="h-4 w-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end" className="w-48"> | |
| {showRemoveFromNotebook && ( | |
| <> | |
| <DropdownMenuItem | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| handleRemoveFromNotebook() | |
| }} | |
| disabled={!onRemoveFromNotebook} | |
| > | |
| <Unlink className="h-4 w-4 mr-2" /> | |
| Remove from Notebook | |
| </DropdownMenuItem> | |
| <DropdownMenuSeparator /> | |
| </> | |
| )} | |
| {isFailed && ( | |
| <> | |
| <DropdownMenuItem | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| handleRetry() | |
| }} | |
| disabled={!onRetry} | |
| > | |
| <RefreshCw className="h-4 w-4 mr-2" /> | |
| Retry Processing | |
| </DropdownMenuItem> | |
| <DropdownMenuSeparator /> | |
| </> | |
| )} | |
| <DropdownMenuItem | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| handleDelete() | |
| }} | |
| disabled={!onDelete} | |
| className="text-red-600 focus:text-red-600" | |
| > | |
| <Trash2 className="h-4 w-4 mr-2" /> | |
| Delete Source | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| </div> | |
| {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} | |
| {(isFailed as any) && ( | |
| <div className="flex gap-2 pt-2 border-t"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleRetry} | |
| disabled={!onRetry} | |
| className="h-7 text-xs" | |
| > | |
| <RefreshCw className="h-3 w-3 mr-1" /> | |
| Retry | |
| </Button> | |
| </div> | |
| )} | |
| {/* Processing progress indicator */} | |
| {isProcessing && statusData?.processing_info?.progress && ( | |
| <div className="mt-3 pt-2 border-t"> | |
| <div className="flex justify-between items-center mb-1"> | |
| <span className="text-xs text-gray-600">Progress</span> | |
| <span className="text-xs text-gray-600"> | |
| {Math.round(statusData.processing_info.progress as number)}% | |
| </span> | |
| </div> | |
| <div className="w-full bg-gray-200 rounded-full h-1.5"> | |
| <div | |
| className="bg-blue-600 h-1.5 rounded-full transition-all duration-300" | |
| style={{ width: `${statusData.processing_info.progress as number}%` }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ) | |
| } | |