Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/src/components/AttachmentGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,16 @@ export const AttachmentGallery: React.FC<AttachmentGalleryProps> = ({

{/* Other Files */}
{otherFiles.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
<div className="space-y-1">
<h4 className="flex flex-wrap justify-end text-xs font-medium text-gray-700 dark:text-gray-300">
Files ({otherFiles.length})
</h4>
<div className="space-y-2">
<div className="flex justify-end flex-wrap gap-2">
{otherFiles.map((file) => (
<AttachmentPreview
key={file.fileId}
attachment={file}
className="w-full"
className="w-[40%]"
/>
))}
</div>
Expand Down
98 changes: 17 additions & 81 deletions frontend/src/components/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import React, { useState } from "react"
import { AttachmentMetadata } from "shared/types"
import {
Download,
Eye,
FileText,
Image,
Video,
Music,
Archive,
File,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Expand All @@ -17,27 +10,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { authFetch } from "@/utils/authFetch"
import { getFileType } from "@/utils/fileUtils"
import { getFileIcon } from "@/components/ChatBox"

interface AttachmentPreviewProps {
attachment: AttachmentMetadata
className?: string
}

const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return Image
if (fileType.startsWith("video/")) return Video
if (fileType.startsWith("audio/")) return Music
if (fileType.includes("pdf") || fileType.includes("document")) return FileText
if (
fileType.includes("zip") ||
fileType.includes("tar") ||
fileType.includes("gz")
)
return Archive
return File
}

const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
Expand All @@ -53,46 +33,11 @@ export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({
const [showImageModal, setShowImageModal] = useState(false)
const [imageError, setImageError] = useState(false)

const FileIcon = getFileIcon(attachment.fileType)
const isImage = attachment.isImage && !imageError
const thumbnailUrl = attachment.thumbnailPath
? `/api/v1/attachments/${attachment.fileId}/thumbnail`
: null

const handleDownload = async () => {
let url: string | null = null
try {
const response = await authFetch(
`/api/v1/attachments/${attachment.fileId}`,
{
credentials: "include",
},
)
if (!response.ok) {
if (response.status === 401) {
throw new Error("Please log in to download attachments")
}
throw new Error(`Download failed: ${response.statusText}`)
}

const blob = await response.blob()
url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = attachment.fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch (error) {
console.error("Download failed:", error)
alert(error instanceof Error ? error.message : "Download failed")
} finally {
if (url) {
window.URL.revokeObjectURL(url)
}
}
}

const handleImageView = () => {
if (isImage) {
setShowImageModal(true)
Expand Down Expand Up @@ -126,7 +71,7 @@ export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({
</div>
) : (
<div className="w-12 h-12 flex items-center justify-center bg-gray-200 dark:bg-gray-700 rounded-md">
<FileIcon className="w-6 h-6 text-gray-600 dark:text-gray-400" />
{getFileIcon(getFileType({type: attachment.fileType, name: attachment.fileName}))}
</div>
)}
</div>
Expand All @@ -137,33 +82,24 @@ export const AttachmentPreview: React.FC<AttachmentPreviewProps> = ({
{attachment.fileName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(attachment.fileSize)} • {attachment.fileType}
{formatFileSize(attachment.fileSize)} • {getFileType({type: attachment.fileType, name: attachment.fileName})}
</p>
</div>

{/* Actions */}
<div className="flex items-center space-x-2">
{isImage && (
<Button
variant="ghost"
size="sm"
onClick={handleImageView}
className="p-2"
aria-label={`Preview ${attachment.fileName}`}
>
<Eye className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
className="p-2"
aria-label={`Download ${attachment.fileName}`}
>
<Download className="w-4 h-4" />
</Button>
</div>
{isImage && (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={handleImageView}
className="p-2"
aria-label={`Preview ${attachment.fileName}`}
>
<Eye className="w-4 h-4" />
</Button>
</div>
)}

{/* Image Modal */}
{isImage && (
Expand Down
78 changes: 59 additions & 19 deletions frontend/src/components/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
X,
File,
Loader2,
FileText,
FileSpreadsheet,
Presentation,
FileImage,
Globe,
} from "lucide-react"
import { siOpenai, siClaude, siGooglegemini } from "simple-icons"
Expand All @@ -43,6 +47,7 @@ import {
UserRole,
DataSourceEntity,
AttachmentMetadata,
FileType,
} from "shared/types" // Add SelectPublicAgent, PublicUser
import {
DropdownMenu,
Expand Down Expand Up @@ -74,6 +79,7 @@ import {
createToastNotifier,
createImagePreview,
cleanupPreviewUrls,
getFileType,
} from "@/utils/fileUtils"
import { authFetch } from "@/utils/authFetch"

Expand All @@ -84,6 +90,26 @@ interface SelectedFile {
uploading?: boolean
uploadError?: string
preview?: string // URL for image preview
fileType?: string
}

export const getFileIcon = (fileType: FileType | string | undefined) => {
switch (fileType) {
case FileType.IMAGE:
return <FileImage size={24} className="text-blue-500 dark:text-blue-400 flex-shrink-0" />
case FileType.DOCUMENT:
return <FileText size={24} className="text-blue-600 dark:text-blue-400 flex-shrink-0" />
case FileType.SPREADSHEET:
return <FileSpreadsheet size={24} className="text-green-600 dark:text-green-400 flex-shrink-0" />
case FileType.PRESENTATION:
return <Presentation size={24} className="text-orange-600 dark:text-orange-400 flex-shrink-0" />
case FileType.PDF:
return <FileText size={24} className="text-red-600 dark:text-red-400 flex-shrink-0" />
case FileType.TEXT:
return <FileText size={24} className="text-gray-600 dark:text-gray-400 flex-shrink-0" />
default:
return <File size={24} className="text-gray-500 dark:text-gray-400 flex-shrink-0" />
}
}

// Add attachment limit constant
Expand Down Expand Up @@ -708,6 +734,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
id: generateFileId(),
uploading: false,
preview: createImagePreview(file),
fileType: getFileType({ type: file.type, name: file.name }),
}))

setSelectedFiles((prev) => {
Expand Down Expand Up @@ -2325,10 +2352,10 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(

const items = e.clipboardData?.items
if (items) {
// Handle image paste
// Handle file paste (all supported file types)
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf("image") !== -1) {
if (item.kind === "file") {
// Check attachment limit before processing
if (selectedFiles.length >= MAX_ATTACHMENTS) {
showToast(
Expand All @@ -2341,15 +2368,27 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(

const file = item.getAsFile()
if (file) {
// Process the pasted image file directly
// The file will be processed with a default name by the processFiles function
processFiles([file])
showToast(
"Image pasted",
"Image has been added to your message.",
false,
)
return // Exit early since we handled the image
// Check if the file type is supported
const isValid = validateAndDeduplicateFiles([file], showToast)
if (isValid.length > 0) {
// Process the pasted file
processFiles([file])
const fileType = getFileType({ type: file.type, name: file.name })

showToast(
"File pasted",
`${fileType} has been added to your message.`,
false,
)
return // Exit early since we handled the file
} else {
showToast(
"Unsupported file type",
"This file type is not supported for attachments.",
true,
)
return
}
}
}
}
Expand Down Expand Up @@ -2678,14 +2717,14 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
? `${selectedFile.file.name.substring(0, 9)}...`
: selectedFile.file.name}
</span>
<span className="text-white text-xs opacity-80 block">
{selectedFile.fileType}
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors min-w-0">
<File
size={24}
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
/>
{getFileIcon(selectedFile.fileType)}
<div className="flex-1 min-w-0">
<span
className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[120px]"
Expand All @@ -2697,7 +2736,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
className="text-xs text-gray-500 dark:text-gray-400 truncate block max-w-[120px]"
title={getExtension(selectedFile.file)}
>
{getExtension(selectedFile.file)}
{selectedFile.fileType}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
Expand Down Expand Up @@ -2761,7 +2800,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
title={
selectedFiles.length >= MAX_ATTACHMENTS
? `Maximum ${MAX_ATTACHMENTS} attachments allowed`
: "Attach files"
: "Attach files (images, documents, spreadsheets, presentations, PDFs, text files)"
}
/>

Expand Down Expand Up @@ -3746,9 +3785,10 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
multiple
className="hidden"
onChange={handleFileChange}
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,text/plain,text/csv,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.md"
title="Supported file types: Images (JPEG, PNG, GIF, WebP), Documents (DOC, DOCX), Spreadsheets (XLS, XLSX, CSV), Presentations (PPT, PPTX), PDFs, and Text files (TXT, MD)"
/>
</div>
)
},
)
)
2 changes: 1 addition & 1 deletion frontend/src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
createImagePreview,
cleanupPreviewUrls,
} from "@/utils/fileUtils"
import { isValidFile } from "../../../shared/filesutils"
import { isValidFile } from "shared/fileUtils"
import { authFetch } from "@/utils/authFetch"

interface SelectedFile {
Expand Down
38 changes: 24 additions & 14 deletions frontend/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import { isValidFile } from "../../../shared/filesutils"
import { isValidFile, isImageFile } from "shared/fileUtils"
import { SelectedFile } from "@/components/ClFileUpload"
import { authFetch } from "./authFetch"
import { FileType, MIME_TYPE_MAPPINGS, EXTENSION_MAPPINGS } from "shared/types"

// Generate unique ID for files
export const generateFileId = () => Math.random().toString(36).substring(2, 9)

// Check if file is an image
const isImageFile = (file: File): boolean => {
return (
file.type.startsWith("image/") &&
[
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
].includes(file.type)
)
export const getFileType = ({ type, name }: { type: string, name: string }): FileType => {
const fileName = name.toLowerCase()
const mimeType = type.toLowerCase()

// Check each file type category using the mappings
for (const [fileType, mimeTypes] of Object.entries(MIME_TYPE_MAPPINGS)) {
// Check MIME type first (more reliable)
if (mimeTypes.some(mime => mimeType === mime || mimeType.includes(mime))) {
return fileType as FileType
}
}

// Fallback to extension-based detection
for (const [fileType, extensions] of Object.entries(EXTENSION_MAPPINGS)) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return fileType as FileType
}
}

// Default fallback
return FileType.FILE
}

// Create preview URL for image files
export const createImagePreview = (file: File): string | undefined => {
if (isImageFile(file)) {
if (isImageFile(file.type)) {
return URL.createObjectURL(file)
}
return undefined
Expand Down
Loading