Skip to content

Commit 8edbd50

Browse files
feat(images): multi-image upload, clickable drop zone, remote-asset template (#57)
- Upload of multiple images now possible - Upload area clickable, opens file browser - Remote-asset area extracted into own template
1 parent caed913 commit 8edbd50

9 files changed

Lines changed: 870 additions & 133 deletions

File tree

src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -414,17 +414,7 @@
414414
</div>
415415

416416
{# Remote assets sidebar - only show if project has manifest URLs configured #}
417-
{% if hasRemoteAssets %}
418-
{% include '@remote_content_assets.presentation/_remote_asset_browser_sidebar.html.twig' with {
419-
project: project,
420-
workspaceId: workspace.id,
421-
windowSize: remoteAssetBrowserWindowSize,
422-
parentStimulusActions: [
423-
{ controller: 'chat-based-content-editor', action: 'handleAssetInsert', event: 'remote-asset-browser:insert' },
424-
{ controller: 'chat-based-content-editor', action: 'handleUploadComplete', event: 'remote-asset-browser:uploadComplete' }
425-
]
426-
} %}
427-
{% endif %}
417+
{% include '@remote_content_assets.presentation/remote_asset_area.twig' %}
428418
</div>
429419

430420
{# Info section with cross-links - subtle placement at bottom #}

src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
1415
export 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

src/RemoteContentAssets/Presentation/Resources/templates/_remote_asset_browser_sidebar.html.twig

Lines changed: 0 additions & 82 deletions
This file was deleted.

0 commit comments

Comments
 (0)