open-notebook / frontend /src /components /source /SourceDetailContent.tsx
baveshraam's picture
FIX: SurrealDB 2.0 migration syntax and Frontend/CORS link
f871fed
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { isAxiosError } from 'axios'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { sourcesApi } from '@/lib/api/sources'
import { insightsApi, SourceInsightResponse } from '@/lib/api/insights'
import { transformationsApi } from '@/lib/api/transformations'
import { embeddingApi } from '@/lib/api/embedding'
import { SourceDetailResponse } from '@/lib/types/api'
import { Transformation } from '@/lib/types/transformations'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { InlineEdit } from '@/components/common/InlineEdit'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Link as LinkIcon,
Upload,
AlignLeft,
ExternalLink,
Download,
Copy,
CheckCircle,
Youtube,
MoreVertical,
Trash2,
Sparkles,
Plus,
Lightbulb,
Database,
AlertCircle,
MessageSquare,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { toast } from 'sonner'
import { SourceInsightDialog } from '@/components/source/SourceInsightDialog'
import { NotebookAssociations } from '@/components/source/NotebookAssociations'
interface SourceDetailContentProps {
sourceId: string
showChatButton?: boolean
onChatClick?: () => void
onClose?: () => void
}
export function SourceDetailContent({
sourceId,
showChatButton = false,
onChatClick,
onClose
}: SourceDetailContentProps) {
const [source, setSource] = useState<SourceDetailResponse | null>(null)
const [insights, setInsights] = useState<SourceInsightResponse[]>([])
const [transformations, setTransformations] = useState<Transformation[]>([])
const [selectedTransformation, setSelectedTransformation] = useState<string>('')
const [loading, setLoading] = useState(true)
const [loadingInsights, setLoadingInsights] = useState(false)
const [creatingInsight, setCreatingInsight] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [isEmbedding, setIsEmbedding] = useState(false)
const [isDownloadingFile, setIsDownloadingFile] = useState(false)
const [fileAvailable, setFileAvailable] = useState<boolean | null>(null)
const [selectedInsight, setSelectedInsight] = useState<SourceInsightResponse | null>(null)
const [insightToDelete, setInsightToDelete] = useState<string | null>(null)
const [deletingInsight, setDeletingInsight] = useState(false)
const fetchSource = useCallback(async () => {
try {
setLoading(true)
const data = await sourcesApi.get(sourceId)
setSource(data)
if (typeof data.file_available === 'boolean') {
setFileAvailable(data.file_available)
} else if (!data.asset?.file_path) {
setFileAvailable(null)
} else {
setFileAvailable(null)
}
} catch (err) {
console.error('Failed to fetch source:', err)
setError('Failed to load source details')
} finally {
setLoading(false)
}
}, [sourceId])
const fetchInsights = useCallback(async () => {
try {
setLoadingInsights(true)
const data = await insightsApi.listForSource(sourceId)
setInsights(data)
} catch (err) {
console.error('Failed to fetch insights:', err)
} finally {
setLoadingInsights(false)
}
}, [sourceId])
const fetchTransformations = useCallback(async () => {
try {
const data = await transformationsApi.list()
setTransformations(data)
} catch (err) {
console.error('Failed to fetch transformations:', err)
}
}, [])
useEffect(() => {
if (sourceId) {
void fetchSource()
void fetchInsights()
void fetchTransformations()
}
}, [fetchInsights, fetchSource, fetchTransformations, sourceId])
const createInsight = async () => {
if (!selectedTransformation) {
toast.error('Please select a transformation')
return
}
try {
setCreatingInsight(true)
await insightsApi.create(sourceId, {
transformation_id: selectedTransformation
})
toast.success('Insight created successfully')
await fetchInsights()
setSelectedTransformation('')
} catch (err) {
console.error('Failed to create insight:', err)
toast.error('Failed to create insight')
} finally {
setCreatingInsight(false)
}
}
const handleDeleteInsight = async (e?: React.MouseEvent) => {
e?.preventDefault()
if (!insightToDelete) return
try {
setDeletingInsight(true)
await insightsApi.delete(insightToDelete)
toast.success('Insight deleted successfully')
setInsightToDelete(null)
await fetchInsights()
} catch (err) {
console.error('Failed to delete insight:', err)
toast.error('Failed to delete insight')
} finally {
setDeletingInsight(false)
}
}
const handleUpdateTitle = async (title: string) => {
if (!source || title === source.title) return
try {
await sourcesApi.update(sourceId, { title })
toast.success('Source title updated')
setSource({ ...source, title })
} catch (err) {
console.error('Failed to update source title:', err)
toast.error('Failed to update source title')
await fetchSource()
}
}
const handleEmbedContent = async () => {
if (!source) return
try {
setIsEmbedding(true)
const response = await embeddingApi.embedContent(sourceId, 'source')
toast.success(response.message)
await fetchSource()
} catch (err) {
console.error('Failed to embed content:', err)
toast.error('Failed to embed content')
} finally {
setIsEmbedding(false)
}
}
const extractFilename = (pathOrUrl: string | undefined, fallback: string) => {
if (!pathOrUrl) {
return fallback
}
const segments = pathOrUrl.split(/[/\\]/)
return segments.pop() || fallback
}
const parseContentDisposition = (header?: string | null) => {
if (!header) {
return null
}
const match = header.match(/filename\*?=([^;]+)/i)
if (!match) {
return null
}
const value = match[1].trim()
if (value.toLowerCase().startsWith("utf-8''")) {
return decodeURIComponent(value.slice(7))
}
return value.replace(/^["']|["']$/g, '')
}
const handleDownloadFile = async () => {
if (!source?.asset?.file_path || isDownloadingFile || fileAvailable === false) {
return
}
try {
setIsDownloadingFile(true)
const response = await sourcesApi.downloadFile(source.id)
const filenameFromHeader = parseContentDisposition(
response.headers?.['content-disposition'] as string | undefined
)
const fallbackName = extractFilename(source.asset.file_path, `source-${source.id}`)
const filename = filenameFromHeader || fallbackName
const blobUrl = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
setFileAvailable(true)
toast.success('Download started')
} catch (err) {
console.error('Failed to download file:', err)
if (isAxiosError(err) && err.response?.status === 404) {
setFileAvailable(false)
toast.error('Original file is no longer available on the server')
} else {
toast.error('Failed to download file')
}
} finally {
setIsDownloadingFile(false)
}
}
const getSourceIcon = () => {
if (!source) return null
if (source.asset?.url) return <LinkIcon className="h-5 w-5" />
if (source.asset?.file_path) return <Upload className="h-5 w-5" />
return <AlignLeft className="h-5 w-5" />
}
const getSourceType = () => {
if (!source) return 'unknown'
if (source.asset?.url) return 'link'
if (source.asset?.file_path) return 'file'
return 'text'
}
const handleCopyUrl = useCallback(() => {
if (source?.asset?.url) {
navigator.clipboard.writeText(source.asset.url)
setCopied(true)
toast.success('URL copied to clipboard')
setTimeout(() => setCopied(false), 2000)
}
}, [source])
const handleOpenExternal = useCallback(() => {
if (source?.asset?.url) {
window.open(source.asset.url, '_blank')
}
}, [source])
const getYouTubeVideoId = (url: string): string | null => {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
const isYouTubeUrl = useMemo(() => {
if (!source?.asset?.url) return false
return !!(getYouTubeVideoId(source.asset.url))
}, [source?.asset?.url])
const youTubeVideoId = useMemo(() => {
if (!source?.asset?.url) return null
return getYouTubeVideoId(source.asset.url)
}, [source?.asset?.url])
const handleDelete = async () => {
if (!source) return
if (confirm('Are you sure you want to delete this source?')) {
try {
await sourcesApi.delete(source.id)
toast.success('Source deleted successfully')
onClose?.()
} catch (error) {
console.error('Failed to delete source:', error)
toast.error('Failed to delete source')
}
}
}
if (loading) {
return (
<div className="flex h-full items-center justify-center p-8">
<LoadingSpinner />
</div>
)
}
if (error || !source) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-red-500">{error || 'Source not found'}</p>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pb-4 px-2">
<div className="flex items-start justify-between">
<div className="flex-1">
<InlineEdit
value={source.title || ''}
onSave={handleUpdateTitle}
className="text-2xl font-bold"
inputClassName="text-2xl font-bold"
placeholder="Source title"
emptyText="Untitled Source"
/>
<p className="mt-1 text-sm text-muted-foreground">
Source ID: {source.id}
</p>
</div>
<div className="flex items-center gap-2">
{getSourceIcon()}
<Badge variant="secondary" className="text-sm">
{getSourceType()}
</Badge>
{/* Chat with source button - only in modal */}
{showChatButton && onChatClick && (
<Button variant="outline" size="sm" onClick={onChatClick}>
<MessageSquare className="h-4 w-4 mr-2" />
Chat with source
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{source.asset?.file_path && (
<>
<DropdownMenuItem
onClick={handleDownloadFile}
disabled={isDownloadingFile || fileAvailable === false}
>
<Download className="mr-2 h-4 w-4" />
{fileAvailable === false
? 'File unavailable'
: isDownloadingFile
? 'Preparing download…'
: 'Download File'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={handleEmbedContent}
disabled={isEmbedding || source.embedded}
>
<Database className="mr-2 h-4 w-4" />
{isEmbedding ? 'Embedding...' : source.embedded ? 'Already Embedded' : 'Embed Content'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={handleDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Source
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Tabs Content */}
<div className="flex-1 overflow-y-auto px-2">
<Tabs defaultValue="content" className="w-full">
<TabsList className="grid w-full grid-cols-3 sticky top-0 z-10">
<TabsTrigger value="content">Content</TabsTrigger>
<TabsTrigger value="insights">
Insights {insights.length > 0 && `(${insights.length})`}
</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="content" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isYouTubeUrl && <Youtube className="h-5 w-5" />}
Content
</CardTitle>
{source.asset?.url && !isYouTubeUrl && (
<CardDescription className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
<a
href={source.asset.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-blue-600"
>
{source.asset.url}
</a>
</CardDescription>
)}
</CardHeader>
<CardContent>
{isYouTubeUrl && youTubeVideoId && (
<div className="mb-6">
<div className="aspect-video rounded-lg overflow-hidden bg-black">
<iframe
src={`https://www.youtube.com/embed/${youTubeVideoId}`}
title="YouTube video"
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
{source.asset?.url && (
<div className="mt-2">
<a
href={source.asset.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Open on YouTube
</a>
</div>
)}
</div>
)}
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-relaxed prose-li:mb-2">
{source.full_text ? (
<div className="text-base leading-relaxed text-justify break-words hyphens-auto" lang="en">
{String(source.full_text)
.trim()
// Replace single newlines with spaces, but preserve paragraph breaks (double newlines)
.replace(/([^\n])\n(?!\n)/g, '$1 ')
// Clean up multiple spaces
.replace(/ +/g, ' ')
// Split into paragraphs and render with proper spacing
.split(/\n\n+/)
.map((paragraph, idx) => (
<p key={idx} className="mb-4 last:mb-0">
{paragraph.trim()}
</p>
))
}
</div>
) : (
<p className="text-muted-foreground">No content available</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="insights" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Lightbulb className="h-5 w-5" />
Insights
</span>
<Badge variant="secondary">{insights.length}</Badge>
</CardTitle>
<CardDescription>
AI-generated insights about this source
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Create New Insight */}
<div className="rounded-lg border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Generate New Insight
</h3>
<div className="flex gap-2">
<Select
value={selectedTransformation}
onValueChange={setSelectedTransformation}
disabled={creatingInsight}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a transformation..." />
</SelectTrigger>
<SelectContent>
{transformations.map((trans) => (
<SelectItem key={trans.id} value={trans.id}>
{trans.title || trans.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={createInsight}
disabled={!selectedTransformation || creatingInsight}
>
{creatingInsight ? (
<>
<LoadingSpinner className="mr-2 h-3 w-3" />
Creating...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create
</>
)}
</Button>
</div>
</div>
{/* Insights List */}
{loadingInsights ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : insights.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Lightbulb className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No insights yet</p>
<p className="text-xs mt-1">Create your first insight using a transformation above</p>
</div>
) : (
<div className="space-y-3">
{insights.map((insight) => (
<div key={insight.id} className="rounded-lg border bg-background p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs uppercase">
{insight.insight_type}
</Badge>
</div>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{insight.content.slice(0, 180)}{insight.content.length > 180 ? '…' : ''}
</p>
<div className="mt-3 flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setSelectedInsight(insight)}>
View Insight
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setInsightToDelete(insight.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="details" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Embedding Alert */}
{!source.embedded && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>
Content Not Embedded
</AlertTitle>
<AlertDescription>
This content hasn&apos;t been embedded for vector search. Embedding enables advanced search capabilities and better content discovery.
<div className="mt-3">
<Button
onClick={handleEmbedContent}
disabled={isEmbedding}
size="sm"
>
<Database className="mr-2 h-4 w-4" />
{isEmbedding ? 'Embedding...' : 'Embed Content'}
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Source Information */}
<div className="space-y-4">
{source.asset?.url && (
<div>
<h3 className="mb-2 text-sm font-semibold">URL</h3>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm">
{source.asset.url}
</code>
<Button
size="sm"
variant="outline"
onClick={handleCopyUrl}
>
{copied ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleOpenExternal}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
)}
{source.asset?.file_path && (
<div className="space-y-2">
<h3 className="text-sm font-semibold">Uploaded File</h3>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-sm">
{source.asset.file_path}
</code>
<Button
size="sm"
variant="outline"
onClick={handleDownloadFile}
disabled={isDownloadingFile || fileAvailable === false}
>
<Download className="mr-2 h-4 w-4" />
{fileAvailable === false
? 'Unavailable'
: isDownloadingFile
? 'Preparing…'
: 'Download'}
</Button>
</div>
{fileAvailable === false ? (
<p className="text-xs text-muted-foreground">
Original file is no longer available on the server (likely removed after
processing). Upload it again if you need a fresh copy.
</p>
) : null}
</div>
)}
{source.topics && source.topics.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold">Topics</h3>
<div className="flex flex-wrap gap-2">
{source.topics.map((topic, idx) => (
<Badge key={idx} variant="outline">
{topic}
</Badge>
))}
</div>
</div>
)}
</div>
{/* Metadata */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Metadata</h3>
<div className="flex items-center gap-2">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
{source.embedded ? "Embedded" : "Not Embedded"}
</Badge>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Created</p>
<p className="text-sm">
{formatDistanceToNow(new Date(source.created), { addSuffix: true })}
</p>
<p className="text-xs text-muted-foreground">
{new Date(source.created).toLocaleString()}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Updated</p>
<p className="text-sm">
{formatDistanceToNow(new Date(source.updated), { addSuffix: true })}
</p>
<p className="text-xs text-muted-foreground">
{new Date(source.updated).toLocaleString()}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notebook Associations */}
<NotebookAssociations
sourceId={sourceId}
currentNotebookIds={source.notebooks || []}
onSave={fetchSource}
/>
</TabsContent>
</Tabs>
</div>
<SourceInsightDialog
open={Boolean(selectedInsight)}
onOpenChange={(open) => {
if (!open) {
setSelectedInsight(null)
}
}}
insight={selectedInsight ?? undefined}
onDelete={async (insightId) => {
try {
await insightsApi.delete(insightId)
toast.success('Insight deleted successfully')
setSelectedInsight(null)
await fetchInsights()
} catch (err) {
console.error('Failed to delete insight:', err)
toast.error('Failed to delete insight')
}
}}
/>
<AlertDialog open={!!insightToDelete} onOpenChange={() => setInsightToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Insight?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This insight will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletingInsight}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={handleDeleteInsight}
disabled={deletingInsight}
variant="destructive"
>
{deletingInsight ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}