diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 60b4ca6..d47cacb 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -51,6 +51,12 @@ interface Conversation { user_id?: string; } +export type UploadResult = + | { kind: "image"; image_key: string } + | { kind: "file"; file_key: string; name: string; chars: number }; + +export class UploadError extends Error {} + interface QueryResponse { response: string; } @@ -182,16 +188,16 @@ class ConversationService { return await response.json(); } - async uploadImage( + async uploadAttachment( file: File, conversationId: string, - ): Promise<{ image_key: string }> { + ): Promise { const formData = new FormData(); formData.append("file", file); formData.append("conversation_id", conversationId); const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/upload-image`, + `${this.conversationBaseUrl}/upload`, { method: "POST", body: formData, @@ -200,8 +206,8 @@ class ConversationService { ); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to upload image"); + const data = await response.json().catch(() => ({})); + throw new UploadError(data.error || "Failed to upload file"); } return await response.json(); @@ -224,11 +230,15 @@ class ConversationService { onEvent: SSEEventCallback, signal?: AbortSignal, imageKey?: string, + fileKey?: string, ): Promise { const body: Record = { query, conversation_id }; if (imageKey) { body.image_key = imageKey; } + if (fileKey) { + body.file_key = fileKey; + } const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/stream-query`, diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 6248a53..05add45 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble"; import { ActivityTimeline } from "./ActivityTimeline"; import { ThinkingBubble } from "./ThinkingBubble"; import { MessageInput } from "./MessageInput"; +import { DropOverlay } from "./DropOverlay"; import { SidebarContent } from "./SidebarContent"; import { AdminPanel } from "./AdminPanel"; import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel"; @@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { streaming, thinking, messagesLoading, - pendingImage, - setPendingImage, + pendingAttachment, + attachFile, + clearAttachment, + attachmentError, + clearAttachmentError, sendMessage, stopGeneration, error, @@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { [deleteConversation], ); - const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]); - const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]); - const handleLogout = () => { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); @@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { {showAdminPanel && setShowAdminPanel(false)} />} {showScheduler && setShowScheduler(false)} />} -
+
+
Simba @@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { onToggleSimbaMode={handleToggleSimbaMode} isLoading={isLoading} onStop={stopGeneration} - pendingImage={pendingImage} - onImageSelect={handleImageSelect} - onClearImage={handleClearImage} + pendingAttachment={pendingAttachment} + onAttach={attachFile} + onClearAttachment={clearAttachment} + attachmentError={attachmentError} + onDismissAttachmentError={clearAttachmentError} autoFocus />
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { onToggleSimbaMode={handleToggleSimbaMode} isLoading={isLoading} onStop={stopGeneration} - pendingImage={pendingImage} - onImageSelect={handleImageSelect} - onClearImage={handleClearImage} + pendingAttachment={pendingAttachment} + onAttach={attachFile} + onClearAttachment={clearAttachment} + attachmentError={attachmentError} + onDismissAttachmentError={clearAttachmentError} />
diff --git a/raggr-frontend/src/components/DropOverlay.tsx b/raggr-frontend/src/components/DropOverlay.tsx new file mode 100644 index 0000000..8a3b1c9 --- /dev/null +++ b/raggr-frontend/src/components/DropOverlay.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import { Paperclip } from "lucide-react"; + +const hasFiles = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + +type DropOverlayProps = { + onDrop: (file: File) => void; +}; + +/** + * Listens for file drags anywhere in the window and covers its positioned + * parent with a "Drop to attach" target. Drags without files (text, links) + * are ignored so they keep their normal behaviour. + */ +export const DropOverlay = ({ onDrop }: DropOverlayProps) => { + const [active, setActive] = useState(false); + // dragenter/dragleave fire for every element crossed; count to know when + // the drag has really left the window. + const depthRef = useRef(0); + const onDropRef = useRef(onDrop); + onDropRef.current = onDrop; + + useEffect(() => { + const handleEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depthRef.current += 1; + setActive(true); + }; + const handleOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + // Required for the drop event to fire (and to stop the browser + // navigating to the file). + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + }; + const handleLeave = (e: DragEvent) => { + if (!hasFiles(e)) return; + depthRef.current = Math.max(0, depthRef.current - 1); + if (depthRef.current === 0) setActive(false); + }; + const handleDrop = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depthRef.current = 0; + setActive(false); + const file = e.dataTransfer?.files?.[0]; + if (file) onDropRef.current(file); + }; + + window.addEventListener("dragenter", handleEnter); + window.addEventListener("dragover", handleOver); + window.addEventListener("dragleave", handleLeave); + window.addEventListener("drop", handleDrop); + return () => { + window.removeEventListener("dragenter", handleEnter); + window.removeEventListener("dragover", handleOver); + window.removeEventListener("dragleave", handleLeave); + window.removeEventListener("drop", handleDrop); + }; + }, []); + + if (!active) return null; + + return ( +
+
+
+ +
+

+ Drop to attach +

+

Images, PDF, TXT, MD or CSV · up to 10 MB

+
+
+ ); +}; diff --git a/raggr-frontend/src/components/MessageInput.tsx b/raggr-frontend/src/components/MessageInput.tsx index 67883e2..2dd2f43 100644 --- a/raggr-frontend/src/components/MessageInput.tsx +++ b/raggr-frontend/src/components/MessageInput.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useMemo, useRef } from "react"; -import { ArrowUp, ImagePlus, Square, X } from "lucide-react"; +import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react"; import { cn } from "../lib/utils"; +import { + ATTACHMENT_ACCEPT, + attachmentKind, + fileExtension, + formatBytes, +} from "../lib/attachments"; import { Textarea } from "./ui/textarea"; type MessageInputProps = { @@ -12,9 +18,11 @@ type MessageInputProps = { query: string; isLoading: boolean; onStop: () => void; - pendingImage: File | null; - onImageSelect: (file: File) => void; - onClearImage: () => void; + pendingAttachment: File | null; + onAttach: (file: File) => void; + onClearAttachment: () => void; + attachmentError: string | null; + onDismissAttachmentError: () => void; autoFocus?: boolean; }; @@ -28,15 +36,23 @@ export const MessageInput = React.memo( onToggleSimbaMode, isLoading, onStop, - pendingImage, - onImageSelect, - onClearImage, + pendingAttachment, + onAttach, + onClearAttachment, + attachmentError, + onDismissAttachmentError, autoFocus, }: MessageInputProps) => { const fileInputRef = useRef(null); const textareaRef = useRef(null); - // Create blob URL once per file, revoke on cleanup + const pendingImage = + pendingAttachment && attachmentKind(pendingAttachment) === "image" + ? pendingAttachment + : null; + const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null; + + // Create blob URL once per image, revoke on cleanup const previewUrl = useMemo( () => (pendingImage ? URL.createObjectURL(pendingImage) : null), [pendingImage], @@ -59,7 +75,7 @@ export const MessageInput = React.memo( const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - onImageSelect(file); + onAttach(file); } // Reset so the same file can be re-selected e.target.value = ""; @@ -69,19 +85,21 @@ export const MessageInput = React.memo( if (isLoading) return; const items = e.clipboardData?.items; if (!items) return; - for (const item of items) { + for (const item of Array.from(items)) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { e.preventDefault(); - onImageSelect(file); + onAttach(file); return; } } } }; - const canSend = !isLoading && Boolean(query.trim() || pendingImage); + const canSend = !isLoading && Boolean(query.trim() || pendingAttachment); + const FileIcon = + pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText; return (
)} + {/* File chip */} + {pendingFile && ( +
+
+
+ +
+
+
+ {pendingFile.name} +
+
+ {fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "} + {formatBytes(pendingFile.size)} +
+
+ +
+
+ )} + + {attachmentError && ( +
+
+ {attachmentError} + +
+
+ )} + {/* Textarea */}