baveshraam's picture
FIX: SurrealDB 2.0 migration syntax and Frontend/CORS link
f871fed
'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>
)
}