feat: file upload thumbnail (#5088)

This commit is contained in:
Faisal Amir 2025-05-25 01:10:21 +07:00 committed by GitHub
parent 0df92d8bda
commit 484caf04aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 197 additions and 8 deletions

View File

@ -1,7 +1,7 @@
'use client'
import TextareaAutosize from 'react-textarea-autosize'
import { cn } from '@/lib/utils'
import { cn, toGigabytes } from '@/lib/utils'
import { usePrompt } from '@/hooks/usePrompt'
import { useThreads } from '@/hooks/useThreads'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -58,6 +58,15 @@ const ChatInput = ({
const { selectedModel } = useModelProvider()
const { sendMessage } = useChat()
const [message, setMessage] = useState('')
const [uploadedFiles, setUploadedFiles] = useState<
Array<{
name: string
type: string
size: number
base64: string
dataUrl: string
}>
>([])
const handleSendMesage = (prompt: string) => {
if (!selectedModel) {
@ -137,6 +146,122 @@ const ChatInput = ({
[abortControllers]
)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAttachmentClick = () => {
fileInputRef.current?.click()
}
const handleRemoveFile = (indexToRemove: number) => {
setUploadedFiles((prev) =>
prev.filter((_, index) => index !== indexToRemove)
)
}
const getFileTypeFromExtension = (fileName: string): string => {
const extension = fileName.toLowerCase().split('.').pop()
switch (extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'pdf':
return 'application/pdf'
default:
return ''
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
const newFiles: Array<{
name: string
type: string
size: number
base64: string
dataUrl: string
}> = []
Array.from(files).forEach((file) => {
// Check file size
if (file.size > maxSize) {
setMessage(`File is too large. Maximum size is 10MB.`)
// Reset file input to allow re-uploading
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
return
}
// Get file type - use extension as fallback if MIME type is incorrect
const detectedType = file.type || getFileTypeFromExtension(file.name)
const actualType = getFileTypeFromExtension(file.name) || detectedType
// Check file type
const allowedTypes = [
'image/jpg',
'image/jpeg',
'image/png',
'application/pdf',
]
if (!allowedTypes.includes(actualType)) {
setMessage(
`File is not supported. Only JPEG, JPG, PNG, and PDF files are allowed.`
)
// Reset file input to allow re-uploading
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
return
}
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result === 'string') {
const base64String = result.split(',')[1]
const fileData = {
name: file.name,
size: file.size,
type: actualType,
base64: base64String,
dataUrl: result,
}
newFiles.push(fileData)
// Update state
if (
newFiles.length ===
Array.from(files).filter((f) => {
const fType = getFileTypeFromExtension(f.name) || f.type
return f.size <= maxSize && allowedTypes.includes(fType)
}).length
) {
setUploadedFiles((prev) => {
const updated = [...prev, ...newFiles]
return updated
})
// Reset the file input value to allow re-uploading the same file
if (fileInputRef.current) {
fileInputRef.current.value = ''
setMessage('')
}
}
}
}
reader.readAsDataURL(file)
})
}
if (textareaRef.current) {
textareaRef.current.focus()
}
}
return (
<div className="relative">
<div className="relative">
@ -157,12 +282,61 @@ const ChatInput = ({
</MovingBorder>
</div>
)}
<div
className={cn(
'relative z-20 px-0 pb-10 border border-main-view-fg/5 rounded-lg text-main-view-fg bg-main-view',
isFocused && 'ring-1 ring-main-view-fg/10'
)}
>
{uploadedFiles.length > 0 && (
<div className="flex gap-3 items-center p-2 pb-0">
{uploadedFiles.map((file, index) => {
return (
<div
key={index}
className={cn(
'relative border border-main-view-fg/5 rounded-lg',
file.type.startsWith('image/') ? 'size-14' : 'h-14 '
)}
>
{file.type.startsWith('image/') && (
<img
className="object-cover w-full h-full rounded-lg"
src={file.dataUrl}
alt={`${file.name} - ${index}`}
/>
)}
{file.type === 'application/pdf' && (
<div className="bg-main-view-fg/4 h-full rounded-lg p-2 max-w-[400px] pr-4">
<div className="flex gap-2 items-center justify-center h-full">
<div className="size-10 rounded-md bg-main-view shrink-0 flex items-center justify-center">
<span className="uppercase font-bold">
{file.name.split('.').pop()}
</span>
</div>
<div className="truncate">
<h6 className="truncate mb-0.5 text-main-view-fg/80">
{file.name}
</h6>
<p className="text-xs text-main-view-fg/70">
{toGigabytes(file.size)}
</p>
</div>
</div>
</div>
)}
<div
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
onClick={() => handleRemoveFile(index)}
>
<IconX className="text-destructive-fg" size={16} />
</div>
</div>
)
})}
</div>
)}
<TextareaAutosize
ref={textareaRef}
disabled={Boolean(streamingContent)}
@ -215,8 +389,17 @@ const ChatInput = ({
)}
{/* File attachment - always available */}
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
<div
className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1"
onClick={handleAttachmentClick}
>
<IconPaperclip size={18} className="text-main-view-fg/50" />
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
/>
</div>
{/* Microphone - always available - Temp Hide */}
@ -272,7 +455,9 @@ const ChatInput = ({
<Button
variant="destructive"
size="icon"
onClick={() => stopStreaming(currentThreadId ?? streamingContent.thread_id)}
onClick={() =>
stopStreaming(currentThreadId ?? streamingContent.thread_id)
}
>
<IconPlayerStopFilled />
</Button>
@ -293,7 +478,7 @@ const ChatInput = ({
</div>
</div>
</div>
{message && !selectedModel && (
{message && (
<div className="bg-main-view-fg/2 -mt-0.5 mx-2 pb-2 px-3 pt-1.5 rounded-b-lg text-xs text-destructive transition-all duration-200 ease-in-out">
<div className="flex items-center gap-1 justify-between">
{message}
@ -301,6 +486,10 @@ const ChatInput = ({
className="size-3 text-main-view-fg/30 cursor-pointer"
onClick={() => {
setMessage('')
// Reset file input to allow re-uploading the same file
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
/>
</div>

View File

@ -182,9 +182,9 @@ export const ThreadContent = memo(
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit</p>
@ -331,9 +331,9 @@ export const ThreadContent = memo(
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Metadata</p>