All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
248 lines
9.1 KiB
TypeScript
248 lines
9.1 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { FileText, Loader2, MessageSquare, Trash2, AlertCircle, Plus } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
interface Attachment {
|
|
id: string
|
|
fileName: string
|
|
fileSize: number
|
|
mimeType: string
|
|
status: 'pending' | 'processing' | 'ready' | 'failed'
|
|
pageCount: number | null
|
|
error: string | null
|
|
createdAt: string
|
|
}
|
|
|
|
interface NoteAttachmentsProps {
|
|
noteId: string
|
|
onOpenDocQA: (attachment: Attachment) => void
|
|
onCountChange?: (count: number) => void
|
|
triggerUpload?: number
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
export function NoteAttachments({ noteId, onOpenDocQA, onCountChange, triggerUpload }: NoteAttachmentsProps) {
|
|
const { t } = useLanguage()
|
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
const [uploading, setUploading] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const sectionRef = useRef<HTMLDivElement>(null)
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
const fetchAttachments = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${noteId}/attachments`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const list = data.data || []
|
|
setAttachments(list)
|
|
onCountChange?.(list.length)
|
|
return list
|
|
}
|
|
} catch {}
|
|
return []
|
|
}, [noteId])
|
|
|
|
useEffect(() => {
|
|
fetchAttachments().finally(() => setLoading(false))
|
|
}, [fetchAttachments])
|
|
|
|
useEffect(() => {
|
|
onCountChange?.(attachments.length)
|
|
}, [attachments.length, onCountChange])
|
|
|
|
useEffect(() => {
|
|
const hasPending = attachments.some(a => a.status === 'pending' || a.status === 'processing')
|
|
if (hasPending) {
|
|
pollingRef.current = setInterval(fetchAttachments, 3000)
|
|
} else if (pollingRef.current) {
|
|
clearInterval(pollingRef.current)
|
|
pollingRef.current = null
|
|
}
|
|
return () => {
|
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
}
|
|
}, [attachments, fetchAttachments])
|
|
|
|
useEffect(() => {
|
|
if (triggerUpload && triggerUpload > 0) {
|
|
if (attachments.length > 0) {
|
|
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}
|
|
fileInputRef.current?.click()
|
|
}
|
|
}, [triggerUpload, attachments.length])
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
if (file.type !== 'application/pdf') {
|
|
toast.error(t('attachments.onlyPdf') || 'Only PDF files are supported')
|
|
return
|
|
}
|
|
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
toast.error(t('attachments.maxSize') || 'File too large (max 20MB)')
|
|
return
|
|
}
|
|
|
|
setUploading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setAttachments(prev => [data.data, ...prev])
|
|
toast.success(t('attachments.uploaded') || 'File uploaded — analyzing...')
|
|
await fetchAttachments()
|
|
setTimeout(() => {
|
|
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}, 200)
|
|
} else {
|
|
const err = await res.json()
|
|
toast.error(err.error || t('attachments.uploadFailed') || 'Upload failed')
|
|
}
|
|
} catch {
|
|
toast.error(t('attachments.uploadError') || 'Upload error')
|
|
} finally {
|
|
setUploading(false)
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (attachmentId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${noteId}/attachments/${attachmentId}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
setAttachments(prev => prev.filter(a => a.id !== attachmentId))
|
|
toast.success(t('attachments.deleted') || 'Attachment removed')
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (loading) return null
|
|
|
|
return (
|
|
<>
|
|
<input ref={fileInputRef} type="file" accept=".pdf" className="hidden" onChange={handleUpload} />
|
|
|
|
{attachments.length > 0 && (
|
|
<div ref={sectionRef} className="pt-8">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h4 className="text-[11px] uppercase font-bold tracking-[.2em] text-muted-foreground">
|
|
{t('attachments.title') || 'Documents'}
|
|
</h4>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{attachments.map(att => (
|
|
<div
|
|
key={att.id}
|
|
className="relative group border border-border rounded-2xl bg-white dark:bg-white/[0.03] overflow-hidden transition-all hover:border-foreground/15"
|
|
>
|
|
<button
|
|
onClick={() => handleDelete(att.id)}
|
|
className="absolute top-2 right-2 p-1 rounded-lg text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-all z-10"
|
|
title={t('attachments.remove') || 'Remove'}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
|
|
{(att.status === 'pending' || att.status === 'processing') && (
|
|
<div className="p-5">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="p-2.5 bg-primary/10 text-primary rounded-xl">
|
|
<FileText size={20} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium truncate">{att.fileName}</p>
|
|
<p className="text-[10px] text-muted-foreground">{formatFileSize(att.fileSize)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-1">
|
|
<Loader2 size={12} className="animate-spin text-primary" />
|
|
<span className="text-[11px] text-primary font-medium">
|
|
{t('attachments.analyzing') || 'Analyzing document...'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{att.status === 'ready' && (
|
|
<button
|
|
onClick={() => onOpenDocQA(att)}
|
|
className="w-full text-left p-5"
|
|
>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2.5 bg-primary/10 text-primary rounded-xl">
|
|
<FileText size={20} />
|
|
</div>
|
|
<div className="min-w-0 flex-1 pr-4">
|
|
<p className="text-xs font-medium truncate">{att.fileName}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{formatFileSize(att.fileSize)}
|
|
{att.pageCount ? ` · ${att.pageCount} pages` : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-1">
|
|
<MessageSquare size={12} className="text-primary" />
|
|
<span className="text-[11px] text-primary font-medium">
|
|
{t('attachments.askQuestions') || 'Ask questions about this document'}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
{att.status === 'failed' && (
|
|
<div className="p-5">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="p-2.5 bg-destructive/10 text-destructive rounded-xl">
|
|
<FileText size={20} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium truncate">{att.fileName}</p>
|
|
<p className="text-[10px] text-destructive">
|
|
{att.error || (t('attachments.processingFailed') || 'Processing failed')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{uploading && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-4">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span>{t('attachments.uploading') || 'Uploading...'}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|