Files
Momento/memento-note/components/note-attachments.tsx
Antigravity 682f8b7118
Some checks failed
CI / Lint, Test & Build (push) Failing after 47s
CI / Deploy production (on server) (push) Has been skipped
fix(pdf-upload): correction de l ouverture en double et integration de la colonne d embedding manquante pour DocumentChunk
2026-05-24 19:05:39 +00:00

251 lines
9.2 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])
const lastTriggerRef = useRef(0)
useEffect(() => {
if (triggerUpload && triggerUpload > lastTriggerRef.current) {
lastTriggerRef.current = triggerUpload
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>
)}
</>
)
}