diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index dae3b4bc..7a6156fb 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -17,6 +17,7 @@ import { selectAttachments, updateAttachment, } from '../upload/uploadSlice'; +import { reorderAttachments } from '../upload/uploadSlice'; import { ActiveState } from '../models/misc'; import { @@ -85,73 +86,85 @@ export default function MessageInput({ const handleFileAttachment = (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; - const file = e.target.files[0]; - const formData = new FormData(); - formData.append('file', file); - + const files = Array.from(e.target.files); const apiHost = import.meta.env.VITE_API_HOST; - const xhr = new XMLHttpRequest(); - const uniqueId = crypto.randomUUID(); - const newAttachment = { - id: uniqueId, - fileName: file.name, - progress: 0, - status: 'uploading' as const, - taskId: '', - }; + files.forEach((file) => { + const formData = new FormData(); + formData.append('file', file); + const xhr = new XMLHttpRequest(); + const uniqueId = crypto.randomUUID(); - dispatch(addAttachment(newAttachment)); + // create preview for images + const isImage = file.type.startsWith('image/'); + const previewUrl = isImage ? URL.createObjectURL(file) : undefined; - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const progress = Math.round((event.loaded / event.total) * 100); - dispatch( - updateAttachment({ - id: uniqueId, - updates: { progress }, - }), - ); - } - }); + const newAttachment = { + id: uniqueId, + fileName: file.name, + previewUrl, + mimeType: file.type, + progress: 0, + status: 'uploading' as const, + taskId: '', + }; - xhr.onload = () => { - if (xhr.status === 200) { - const response = JSON.parse(xhr.responseText); - if (response.task_id) { + dispatch(addAttachment(newAttachment)); + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); dispatch( updateAttachment({ id: uniqueId, - updates: { - taskId: response.task_id, - status: 'processing', - progress: 10, - }, + updates: { progress }, }), ); } - } else { + }); + + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + if (response.task_id) { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { + taskId: response.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } + } else { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { status: 'failed' }, + }), + ); + if (previewUrl) URL.revokeObjectURL(previewUrl); + } + }; + + xhr.onerror = () => { dispatch( updateAttachment({ id: uniqueId, updates: { status: 'failed' }, }), ); - } - }; + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; - xhr.onerror = () => { - dispatch( - updateAttachment({ - id: uniqueId, - updates: { status: 'failed' }, - }), - ); - }; + xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.send(formData); + }); - xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - xhr.send(formData); + // clear input so same file can be selected again e.target.value = ''; }; @@ -261,86 +274,148 @@ export default function MessageInput({ handleAbort(); }; + // Drag state for reordering + const [draggingId, setDraggingId] = useState(null); + + // Revoke object URLs on unmount to avoid memory leaks + useEffect(() => { + return () => { + attachments.forEach((att) => { + if (att.previewUrl) URL.revokeObjectURL(att.previewUrl); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const findIndexById = (id: string) => attachments.findIndex((a) => a.id === id); + + const handleDragStart = (e: React.DragEvent, id: string) => { + setDraggingId(id); + try { + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + } catch (err) { + // ignore + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDropOn = (e: React.DragEvent, targetId: string) => { + e.preventDefault(); + const sourceId = e.dataTransfer.getData('text/plain'); + if (!sourceId || sourceId === targetId) return; + + const sourceIndex = findIndexById(sourceId); + const destIndex = findIndexById(targetId); + if (sourceIndex === -1 || destIndex === -1) return; + + dispatch(reorderAttachments({ sourceIndex, destinationIndex: destIndex })); + setDraggingId(null); + }; + return (
- {attachments.map((attachment) => ( -
-
- {attachment.status === 'completed' && ( - Attachment - )} - - {attachment.status === 'failed' && ( - Failed - )} - - {(attachment.status === 'uploading' || - attachment.status === 'processing') && ( -
- - - - -
- )} -
- - - {attachment.fileName} - - - -
- ))} +
+ {isImage && attachment.previewUrl ? ( + {attachment.fileName} + ) : ( +
+ {attachment.status === 'completed' && ( + Attachment + )} + + {attachment.status === 'failed' && ( + Failed + )} + + {(attachment.status === 'uploading' || + attachment.status === 'processing') && ( +
+ + + + +
+ )} +
+ )} +
+ + + {attachment.fileName} + + + +
+ ); + })}
@@ -422,6 +497,7 @@ export default function MessageInput({ diff --git a/frontend/src/upload/uploadSlice.ts b/frontend/src/upload/uploadSlice.ts index d0b99efe..5c6c6c47 100644 --- a/frontend/src/upload/uploadSlice.ts +++ b/frontend/src/upload/uploadSlice.ts @@ -4,6 +4,10 @@ import { RootState } from '../store'; export interface Attachment { id: string; // Unique identifier for the attachment (required for state management) fileName: string; + // Optional preview URL for image thumbnails (object URL) + previewUrl?: string; + // MIME type of the original file + mimeType?: string; progress: number; status: 'uploading' | 'processing' | 'completed' | 'failed'; taskId: string; // Server-assigned task ID (used for API calls) @@ -66,6 +70,23 @@ export const uploadSlice = createSlice({ (att) => att.id !== action.payload, ); }, + // Reorder attachments array by moving item from sourceIndex to destinationIndex + reorderAttachments: ( + state, + action: PayloadAction<{ sourceIndex: number; destinationIndex: number }>, + ) => { + const { sourceIndex, destinationIndex } = action.payload; + if ( + sourceIndex < 0 || + destinationIndex < 0 || + sourceIndex >= state.attachments.length || + destinationIndex >= state.attachments.length + ) + return; + + const [moved] = state.attachments.splice(sourceIndex, 1); + state.attachments.splice(destinationIndex, 0, moved); + }, clearAttachments: (state) => { state.attachments = state.attachments.filter( (att) => att.status === 'uploading' || att.status === 'processing', @@ -121,6 +142,7 @@ export const { addAttachment, updateAttachment, removeAttachment, + reorderAttachments, clearAttachments, addUploadTask, updateUploadTask,