@@ -9,7 +9,8 @@ import { Controller } from "@hotwired/stimulus";
99 * - Image preview for image URLs
1010 * - Click to open asset in new tab
1111 * - Add to chat button dispatches custom event for insertion
12- * - Drag-and-drop upload to S3 (when configured)
12+ * - Drag-and-drop upload to S3 (when configured, supports multiple files)
13+ * - Clickable dropzone to open file dialog (supports multiple files)
1314 */
1415export default class extends Controller {
1516 static values = {
@@ -30,7 +31,9 @@ export default class extends Controller {
3031 "empty" ,
3132 "search" ,
3233 "dropzone" ,
34+ "fileInput" ,
3335 "uploadProgress" ,
36+ "uploadProgressText" ,
3437 "uploadError" ,
3538 "uploadSuccess" ,
3639 ] ;
@@ -55,8 +58,12 @@ export default class extends Controller {
5558 declare readonly searchTarget : HTMLInputElement ;
5659 declare readonly hasDropzoneTarget : boolean ;
5760 declare readonly dropzoneTarget : HTMLElement ;
61+ declare readonly hasFileInputTarget : boolean ;
62+ declare readonly fileInputTarget : HTMLInputElement ;
5863 declare readonly hasUploadProgressTarget : boolean ;
5964 declare readonly uploadProgressTarget : HTMLElement ;
65+ declare readonly hasUploadProgressTextTarget : boolean ;
66+ declare readonly uploadProgressTextTarget : HTMLElement ;
6067 declare readonly hasUploadErrorTarget : boolean ;
6168 declare readonly uploadErrorTarget : HTMLElement ;
6269 declare readonly hasUploadSuccessTarget : boolean ;
@@ -120,7 +127,7 @@ export default class extends Controller {
120127 }
121128
122129 /**
123- * Handle drop event - upload the file .
130+ * Handle drop event - upload all dropped files .
124131 */
125132 private async handleDrop ( e : DragEvent ) : Promise < void > {
126133 e . preventDefault ( ) ;
@@ -136,52 +143,122 @@ export default class extends Controller {
136143 return ;
137144 }
138145
139- // Only upload the first file
140- await this . uploadFile ( files [ 0 ] ) ;
146+ await this . uploadFiles ( files ) ;
141147 }
142148
143149 /**
144- * Upload a file to S3 .
150+ * Open the native file dialog by clicking the hidden file input .
145151 */
146- private async uploadFile ( file : File ) : Promise < void > {
147- if ( ! this . isUploadEnabled ( ) || this . isUploading ) {
152+ openFileDialog ( ) : void {
153+ if ( ! this . isUploadEnabled ( ) || ! this . hasFileInputTarget || this . isUploading ) {
148154 return ;
149155 }
150156
151- this . isUploading = true ;
152- this . showUploadStatus ( "progress" ) ;
157+ // Reset value so the same file(s) can be re-selected
158+ this . fileInputTarget . value = "" ;
159+ this . fileInputTarget . click ( ) ;
160+ }
153161
154- try {
155- const formData = new FormData ( ) ;
156- formData . append ( "file" , file ) ;
157- formData . append ( "workspace_id" , this . workspaceIdValue ) ;
158- formData . append ( "_csrf_token" , this . uploadCsrfTokenValue ) ;
159-
160- const response = await fetch ( this . uploadUrlValue , {
161- method : "POST" ,
162- headers : {
163- "X-Requested-With" : "XMLHttpRequest" ,
164- } ,
165- body : formData ,
166- } ) ;
162+ /**
163+ * Handle file selection from the native file dialog.
164+ */
165+ handleFileSelect ( e : Event ) : void {
166+ const input = e . target as HTMLInputElement ;
167+ const files = input . files ;
168+ if ( ! files || files . length === 0 ) {
169+ return ;
170+ }
167171
168- const data = ( await response . json ( ) ) as { success ?: boolean ; url ?: string ; error ?: string } ;
172+ void this . uploadFiles ( files ) ;
173+ }
169174
170- if ( data . success && data . url ) {
171- this . showUploadStatus ( "success" ) ;
172- // Notify chat controller about the upload
173- this . dispatch ( "uploadComplete" , { detail : { url : data . url } } ) ;
174- // Re-fetch the asset list to show updated manifests
175- await this . fetchAssets ( ) ;
176- // Auto-hide success message after 3 seconds
177- setTimeout ( ( ) => this . showUploadStatus ( "none" ) , 3000 ) ;
178- } else {
179- this . showUploadError ( data . error || "Upload failed" ) ;
175+ /**
176+ * Upload multiple files sequentially to S3.
177+ */
178+ private async uploadFiles ( files : FileList ) : Promise < void > {
179+ if ( ! this . isUploadEnabled ( ) || this . isUploading ) {
180+ return ;
181+ }
182+
183+ this . isUploading = true ;
184+ const total = files . length ;
185+ let successCount = 0 ;
186+ let errorCount = 0 ;
187+
188+ for ( let i = 0 ; i < total ; i ++ ) {
189+ this . updateUploadProgressText ( i + 1 , total ) ;
190+ this . showUploadStatus ( "progress" ) ;
191+
192+ try {
193+ const uploaded = await this . uploadSingleFile ( files [ i ] ) ;
194+ if ( uploaded ) {
195+ successCount ++ ;
196+ } else {
197+ errorCount ++ ;
198+ }
199+ } catch {
200+ errorCount ++ ;
180201 }
181- } catch {
202+ }
203+
204+ // Re-fetch the asset list once after all uploads
205+ if ( successCount > 0 ) {
206+ await this . fetchAssets ( ) ;
207+ }
208+
209+ // Show final status
210+ if ( errorCount > 0 && successCount === 0 ) {
182211 this . showUploadError ( "Upload failed. Please try again." ) ;
183- } finally {
184- this . isUploading = false ;
212+ } else if ( errorCount > 0 ) {
213+ this . showUploadError ( `${ errorCount } of ${ total } uploads failed.` ) ;
214+ } else {
215+ this . showUploadStatus ( "success" ) ;
216+ setTimeout ( ( ) => this . showUploadStatus ( "none" ) , 3000 ) ;
217+ }
218+
219+ this . isUploading = false ;
220+ }
221+
222+ /**
223+ * Upload a single file to S3. Returns true on success.
224+ */
225+ private async uploadSingleFile ( file : File ) : Promise < boolean > {
226+ const formData = new FormData ( ) ;
227+ formData . append ( "file" , file ) ;
228+ formData . append ( "workspace_id" , this . workspaceIdValue ) ;
229+ formData . append ( "_csrf_token" , this . uploadCsrfTokenValue ) ;
230+
231+ const response = await fetch ( this . uploadUrlValue , {
232+ method : "POST" ,
233+ headers : {
234+ "X-Requested-With" : "XMLHttpRequest" ,
235+ } ,
236+ body : formData ,
237+ } ) ;
238+
239+ const data = ( await response . json ( ) ) as { success ?: boolean ; url ?: string ; error ?: string } ;
240+
241+ if ( data . success && data . url ) {
242+ this . dispatch ( "uploadComplete" , { detail : { url : data . url } } ) ;
243+
244+ return true ;
245+ }
246+
247+ return false ;
248+ }
249+
250+ /**
251+ * Update the upload progress text for multi-file uploads.
252+ */
253+ private updateUploadProgressText ( current : number , total : number ) : void {
254+ if ( ! this . hasUploadProgressTextTarget ) {
255+ return ;
256+ }
257+
258+ if ( total === 1 ) {
259+ this . uploadProgressTextTarget . textContent = "" ;
260+ } else {
261+ this . uploadProgressTextTarget . textContent = `(${ current } /${ total } )` ;
185262 }
186263 }
187264
0 commit comments