baveshraam's picture
FIX: SurrealDB 2.0 migration syntax and Frontend/CORS link
f871fed
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { sourcesApi } from '@/lib/api/sources'
import { SourceListResponse } from '@/lib/types/api'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { EmptyState } from '@/components/common/EmptyState'
import { AppShell } from '@/components/layout/AppShell'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { FileText, Link as LinkIcon, Upload, AlignLeft, Trash2, ArrowUpDown } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
export default function SourcesPage() {
const [sources, setSources] = useState<SourceListResponse[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [sortBy, setSortBy] = useState<'created' | 'updated'>('updated')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; source: SourceListResponse | null }>({
open: false,
source: null
})
const router = useRouter()
const tableRef = useRef<HTMLTableElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const offsetRef = useRef(0)
const loadingMoreRef = useRef(false)
const hasMoreRef = useRef(true)
const PAGE_SIZE = 30
const fetchSources = useCallback(async (reset = false) => {
try {
// Check flags before proceeding
if (!reset && (loadingMoreRef.current || !hasMoreRef.current)) {
return
}
if (reset) {
setLoading(true)
offsetRef.current = 0
setSources([])
hasMoreRef.current = true
} else {
loadingMoreRef.current = true
setLoadingMore(true)
}
const data = await sourcesApi.list({
limit: PAGE_SIZE,
offset: offsetRef.current,
sort_by: sortBy,
sort_order: sortOrder,
})
if (reset) {
setSources(data)
} else {
setSources(prev => [...prev, ...data])
}
// Check if we have more data
const hasMoreData = data.length === PAGE_SIZE
hasMoreRef.current = hasMoreData
offsetRef.current += data.length
} catch (err) {
console.error('Failed to fetch sources:', err)
setError('Failed to load sources')
toast.error('Failed to load sources')
} finally {
setLoading(false)
setLoadingMore(false)
loadingMoreRef.current = false
}
}, [sortBy, sortOrder])
// Initial load and when sort changes
useEffect(() => {
fetchSources(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder])
useEffect(() => {
// Focus the table when component mounts or sources change
if (sources.length > 0 && tableRef.current) {
tableRef.current.focus()
}
}, [sources])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (sources.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => {
const newIndex = Math.min(prev + 1, sources.length - 1)
// Scroll to keep selected row visible
setTimeout(() => scrollToSelectedRow(newIndex), 0)
return newIndex
})
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => {
const newIndex = Math.max(prev - 1, 0)
// Scroll to keep selected row visible
setTimeout(() => scrollToSelectedRow(newIndex), 0)
return newIndex
})
break
case 'Enter':
e.preventDefault()
if (sources[selectedIndex]) {
router.push(`/sources/${sources[selectedIndex].id}`)
}
break
case 'Home':
e.preventDefault()
setSelectedIndex(0)
setTimeout(() => scrollToSelectedRow(0), 0)
break
case 'End':
e.preventDefault()
const lastIndex = sources.length - 1
setSelectedIndex(lastIndex)
setTimeout(() => scrollToSelectedRow(lastIndex), 0)
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [sources, selectedIndex, router])
const scrollToSelectedRow = (index: number) => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
// Find the selected row element
const rows = scrollContainer.querySelectorAll('tbody tr')
const selectedRow = rows[index] as HTMLElement
if (!selectedRow) return
const containerRect = scrollContainer.getBoundingClientRect()
const rowRect = selectedRow.getBoundingClientRect()
// Check if row is above visible area
if (rowRect.top < containerRect.top) {
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// Check if row is below visible area
else if (rowRect.bottom > containerRect.bottom) {
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'end' })
}
}
// Set up scroll listener after sources are loaded
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
let scrollTimeout: NodeJS.Timeout | null = null
const handleScroll = () => {
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
scrollTimeout = setTimeout(() => {
if (!scrollContainerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// Load more when within 200px of the bottom
if (distanceFromBottom < 200 && !loadingMoreRef.current && hasMoreRef.current) {
fetchSources(false)
}
}, 100)
}
scrollContainer.addEventListener('scroll', handleScroll)
handleScroll() // Check on mount
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
}
}, [fetchSources, sources.length])
const toggleSort = (field: 'created' | 'updated') => {
if (sortBy === field) {
// Toggle order if clicking the same field
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
// Switch to new field with default desc order
setSortBy(field)
setSortOrder('desc')
}
}
const getSourceIcon = (source: SourceListResponse) => {
if (source.asset?.url) return <LinkIcon className="h-4 w-4" />
if (source.asset?.file_path) return <Upload className="h-4 w-4" />
return <AlignLeft className="h-4 w-4" />
}
const getSourceType = (source: SourceListResponse) => {
if (source.asset?.url) return 'Link'
if (source.asset?.file_path) return 'File'
return 'Text'
}
const handleRowClick = useCallback((index: number, sourceId: string) => {
setSelectedIndex(index)
router.push(`/sources/${sourceId}`)
}, [router])
const handleDeleteClick = useCallback((e: React.MouseEvent, source: SourceListResponse) => {
e.stopPropagation() // Prevent row click
setDeleteDialog({ open: true, source })
}, [])
const handleDeleteConfirm = async () => {
if (!deleteDialog.source) return
try {
await sourcesApi.delete(deleteDialog.source.id)
toast.success('Source deleted successfully')
// Remove the deleted source from the list
setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))
setDeleteDialog({ open: false, source: null })
} catch (err) {
console.error('Failed to delete source:', err)
toast.error('Failed to delete source')
}
}
if (loading) {
return (
<AppShell>
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
</AppShell>
)
}
if (error) {
return (
<AppShell>
<div className="flex h-full items-center justify-center">
<p className="text-red-500">{error}</p>
</div>
</AppShell>
)
}
if (sources.length === 0) {
return (
<AppShell>
<EmptyState
icon={FileText}
title="No sources yet"
description="Sources from all notebooks will appear here"
/>
</AppShell>
)
}
return (
<AppShell>
<div className="flex flex-col h-full w-full max-w-none px-6 py-6">
<div className="mb-6 flex-shrink-0">
<h1 className="text-3xl font-bold">All Sources</h1>
<p className="mt-2 text-muted-foreground">
Browse all sources across your notebooks. Use arrow keys to navigate and Enter to open.
</p>
</div>
<div ref={scrollContainerRef} className="flex-1 rounded-md border overflow-auto">
<table
ref={tableRef}
tabIndex={0}
className="w-full min-w-[800px] outline-none table-fixed"
>
<colgroup>
<col className="w-[120px]" />
<col className="w-auto" />
<col className="w-[140px]" />
<col className="w-[100px]" />
<col className="w-[100px]" />
<col className="w-[100px]" />
</colgroup>
<thead className="sticky top-0 bg-background z-10">
<tr className="border-b bg-muted/50">
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Type
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSort('created')}
className="h-8 px-2 hover:bg-muted"
>
Created
<ArrowUpDown className={cn(
"ml-2 h-3 w-3",
sortBy === 'created' ? 'opacity-100' : 'opacity-30'
)} />
{sortBy === 'created' && (
<span className="ml-1 text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</Button>
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell">
Insights
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell">
Embedded
</th>
<th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground">
Actions
</th>
</tr>
</thead>
<tbody>
{sources.map((source, index) => (
<tr
key={source.id}
onClick={() => handleRowClick(index, source.id)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"border-b transition-colors cursor-pointer",
selectedIndex === index
? "bg-accent"
: "hover:bg-muted/50"
)}
>
<td className="h-12 px-4">
<div className="flex items-center gap-2">
{getSourceIcon(source)}
<Badge variant="secondary" className="text-xs">
{getSourceType(source)}
</Badge>
</div>
</td>
<td className="h-12 px-4">
<div className="flex flex-col overflow-hidden">
<span className="font-medium truncate">
{source.title || 'Untitled Source'}
</span>
{source.asset?.url && (
<span className="text-xs text-muted-foreground truncate">
{source.asset.url}
</span>
)}
</div>
</td>
<td className="h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell">
{formatDistanceToNow(new Date(source.created), { addSuffix: true })}
</td>
<td className="h-12 px-4 text-center hidden md:table-cell">
<span className="text-sm font-medium">{source.insights_count || 0}</span>
</td>
<td className="h-12 px-4 text-center hidden lg:table-cell">
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
{source.embedded ? "Yes" : "No"}
</Badge>
</td>
<td className="h-12 px-4 text-right">
<Button
variant="ghost"
size="icon"
onClick={(e) => handleDeleteClick(e, source)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{loadingMore && (
<tr>
<td colSpan={6} className="h-16 text-center">
<div className="flex items-center justify-center">
<LoadingSpinner />
<span className="ml-2 text-muted-foreground">Loading more sources...</span>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<ConfirmDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
title="Delete Source"
description={`Are you sure you want to delete "${deleteDialog.source?.title || 'this source'}"? This action cannot be undone.`}
confirmText="Delete"
confirmVariant="destructive"
onConfirm={handleDeleteConfirm}
/>
</AppShell>
)
}