Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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?: FileType
}

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
39 changes: 25 additions & 14 deletions frontend/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
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()
const baseMime = mimeType.split(";")[0]

// 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 => baseMime === 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