From b9334abf18fb7897381f14a6231d3e196d85d68a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 9 Apr 2026 11:56:32 +0100 Subject: [PATCH 1/4] feat: support multiple file uploads in bucket Allow selecting and dropping multiple files at once when uploading to a storage bucket. Files are uploaded in parallel with a concurrency limit of 5 to avoid overwhelming the browser and server. --- src/lib/stores/uploader.ts | 115 ++++-- .../storage/bucket-[bucket]/+page.svelte | 386 +++++++++++------- .../bucket-[bucket]/create/+page.svelte | 40 +- 3 files changed, 340 insertions(+), 201 deletions(-) diff --git a/src/lib/stores/uploader.ts b/src/lib/stores/uploader.ts index 8a04eae8ac..0c2c3bbc4d 100644 --- a/src/lib/stores/uploader.ts +++ b/src/lib/stores/uploader.ts @@ -39,6 +39,8 @@ const temporaryFunctions = (region: string, projectId: string) => { return new Functions(clientProject); }; +const MAX_CONCURRENT_UPLOADS = 5; + const createUploader = () => { const { subscribe, set, update } = writable({ isOpen: false, @@ -58,6 +60,78 @@ const createUploader = () => { }); }; + const uploadFile = async ( + region: string, + projectId: string, + bucketId: string, + id: string, + file: File, + permissions: string[] + ) => { + const newFile: UploaderFile = { + $id: id, + resourceId: bucketId, + name: file.name, + size: file.size, + progress: 0, + status: 'pending' + }; + update((n) => { + n.isOpen = true; + n.isCollapsed = false; + n.files.unshift(newFile); + return n; + }); + const uploadedFile = await temporaryStorage(region, projectId).createFile({ + bucketId, + fileId: id ?? ID.unique(), + file, + permissions, + onProgress: (progress) => { + newFile.$id = progress.$id; + newFile.progress = progress.progress; + newFile.status = progress.progress === 100 ? 'success' : 'pending'; + updateFile(progress.$id, newFile); + } + }); + newFile.$id = uploadedFile.$id; + newFile.progress = 100; + newFile.status = 'success'; + updateFile(newFile.$id, newFile); + }; + + const uploadFiles = async ( + region: string, + projectId: string, + bucketId: string, + files: { id: string; file: File }[], + permissions: string[] + ) => { + const results: PromiseSettledResult[] = []; + const executing = new Set>(); + + for (const { id, file } of files) { + const task = uploadFile(region, projectId, bucketId, id, file, permissions).then( + () => { + results.push({ status: 'fulfilled', value: undefined }); + executing.delete(task); + }, + (reason) => { + results.push({ status: 'rejected', reason }); + executing.delete(task); + } + ); + executing.add(task); + + if (executing.size >= MAX_CONCURRENT_UPLOADS) { + await Promise.race(executing); + } + } + + await Promise.all(executing); + return results; + }; + return { subscribe, @@ -78,45 +152,8 @@ const createUploader = () => { isCollapsed: false, files: [] }), - uploadFile: async ( - region: string, - projectId: string, - bucketId: string, - id: string, - file: File, - permissions: string[] - ) => { - const newFile: UploaderFile = { - $id: id, - resourceId: bucketId, - name: file.name, - size: file.size, - progress: 0, - status: 'pending' - }; - update((n) => { - n.isOpen = true; - n.isCollapsed = false; - n.files.unshift(newFile); - return n; - }); - const uploadedFile = await temporaryStorage(region, projectId).createFile({ - bucketId, - fileId: id ?? ID.unique(), - file, - permissions, - onProgress: (progress) => { - newFile.$id = progress.$id; - newFile.progress = progress.progress; - newFile.status = progress.progress === 100 ? 'success' : 'pending'; - updateFile(progress.$id, newFile); - } - }); - newFile.$id = uploadedFile.$id; - newFile.progress = 100; - newFile.status = 'success'; - updateFile(newFile.$id, newFile); - }, + uploadFile, + uploadFiles, uploadSiteDeployment: async ({ siteId, code, diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte index 886169f114..e17db77997 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte @@ -22,7 +22,7 @@ import { uploader } from '$lib/stores/uploader'; import { sdk } from '$lib/stores/sdk.js'; import DeleteFile from './deleteFile.svelte'; - import { Layout, Table, Icon, Popover, ActionMenu } from '@appwrite.io/pink-svelte'; + import { Layout, Table, Icon, Popover, ActionMenu, Typography } from '@appwrite.io/pink-svelte'; import { onMount } from 'svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; import { @@ -32,6 +32,8 @@ IconTrash } from '@appwrite.io/pink-icons-svelte'; import { isSmallViewport } from '$lib/stores/viewport'; + import { ID } from '@appwrite.io/console'; + import { addNotification } from '$lib/stores/notifications'; import type { PageProps } from './$types'; const { data }: PageProps = $props(); @@ -39,6 +41,7 @@ let showDelete = $state(false); let isUploading = $state(false); let selectedFile: Models.File | null = $state(null); + let isDragging = $state(false); function getPreview(fileId: string) { return ( @@ -98,6 +101,35 @@ } }; + async function handleDrop(event: DragEvent) { + isDragging = false; + if (!event.dataTransfer?.files?.length) return; + + const droppedFiles = Array.from(event.dataTransfer.files); + const count = droppedFiles.length; + + addNotification({ + type: 'success', + message: count === 1 ? 'File upload in progress' : `${count} file uploads in progress` + }); + + trackEvent(Submit.FileCreate, { customId: false }); + + const filesToUpload = droppedFiles.map((file) => ({ + id: ID.unique(), + file + })); + + await uploader.uploadFiles( + page.params.region, + page.params.project, + page.params.bucket, + filesToUpload, + [] + ); + invalidate(Dependencies.FILES); + } + onMount(() => { return uploader.subscribe(() => { isUploading = $uploader.files.some( @@ -110,160 +142,222 @@ - - - - - - - + +
{ + e.preventDefault(); + handleDrop(e); + }} + ondragenter={(e) => { + e.preventDefault(); + isDragging = true; + }} + ondragover={(e) => { + e.preventDefault(); + isDragging = true; + }} + ondragleave={(e) => { + e.preventDefault(); + if (!e.currentTarget.contains(e.relatedTarget as Node)) isDragging = false; + }}> + {#if isDragging} +
+ + + Drop files to upload + +
+ {/if} + + + + + + + + + - - {#if data.files.total} - - {#snippet header(root)} - Filename - Type - Size - Created - - {/snippet} + {#if data.files.total} + + {#snippet header(root)} + Filename + Type + Size + Created + + {/snippet} - {#snippet children(root)} - {#each data.files.files as file} - {#if file.chunksTotal / file.chunksUploaded !== 1} - - - - - {file.name} -
- + {#snippet children(root)} + {#each data.files.files as file} + {#if file.chunksTotal / file.chunksUploaded !== 1} + + + + + {file.name} +
+ +
+
+
+ {file.mimeType} + + {calculateSize(file.sizeOriginal)} + + + + + +
+ +
+
+
+ {:else} + {@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/file-${file.$id}`} + + +
+ + {file.name}
- -
- {file.mimeType} - - {calculateSize(file.sizeOriginal)} - - - - - -
- -
-
- - {:else} - {@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/file-${file.$id}`} - - -
- - {file.name} -
-
- {file.mimeType} - - {calculateSize(file.sizeOriginal)} - - - - - - - - - - Update - - + {file.mimeType} + + {calculateSize(file.sizeOriginal)} + + + + + + + + + + Update + + { + e.stopPropagation(); + e.preventDefault(); + selectedFile = file; + showDelete = true; + }}> + Delete + + + + +
+ {/if} + {/each} + {/snippet} - {#snippet deleteContentNotice()} - This action is irreversible and will permanently remove the selected files. - {/snippet} - + {#snippet deleteContentNotice()} + This action is irreversible and will permanently remove the selected files. + {/snippet} + - - {:else if data.search} - - - - {:else} - - goto( - `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/create` - )} /> - {/if} - + + {:else if data.search} + + + + {:else} + + goto( + `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/create` + )} /> + {/if} + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte index 8242811059..ae3ac85910 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte @@ -42,15 +42,19 @@ let permissions: string[] = []; async function create() { - const fileId = id ?? ID.unique(); + const fileArray = Array.from(files); + const isSingle = fileArray.length === 1; try { - const promise = uploader.uploadFile( + const filesToUpload = fileArray.map((file) => ({ + id: isSingle ? (id ?? ID.unique()) : ID.unique(), + file + })); + const promise = uploader.uploadFiles( page.params.region, page.params.project, page.params.bucket, - fileId, - files[0], + filesToUpload, permissions ); await goto( @@ -58,7 +62,9 @@ ); addNotification({ type: 'success', - message: `File upload in progress` + message: isSingle + ? 'File upload in progress' + : `${fileArray.length} file uploads in progress` }); trackEvent(Submit.FileCreate, { customId: !!id @@ -66,7 +72,6 @@ await promise; invalidate(Dependencies.FILES); } catch (e) { - uploader.removeFromQueue(fileId); addNotification({ type: 'error', message: e.message @@ -94,7 +99,7 @@ 1 ? `Create ${files.length} files` : 'Create file'} bind:showExitModal href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/`}>
@@ -119,6 +124,7 @@ (files = removeFile(e.detail, files))} /> {/if} - {#if !showCustomId} -
- (showCustomId = !showCustomId)}> - - File ID - -
- {:else} - + {#if files?.length === 1} + {#if !showCustomId} +
+ (showCustomId = !showCustomId)}> + + File ID + +
+ {:else} + + {/if} {/if} From b06e45f28103ccfda11125464a1df837b2cf5160 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 9 Apr 2026 12:08:00 +0100 Subject: [PATCH 2/4] fix: mark failed uploads in store, surface errors, validate extensions on drop - uploadFile now catches errors and sets status to 'failed' with error message - Both create page and drag-drop check uploadFiles results for failures and show error notifications - Drag-drop validates file extensions against bucket allowedFileExtensions before uploading, rejecting disallowed files with a notification --- src/lib/stores/uploader.ts | 39 +++++++++++-------- .../storage/bucket-[bucket]/+page.svelte | 34 ++++++++++++++-- .../bucket-[bucket]/create/+page.svelte | 9 ++++- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/lib/stores/uploader.ts b/src/lib/stores/uploader.ts index 0c2c3bbc4d..efc16f2559 100644 --- a/src/lib/stores/uploader.ts +++ b/src/lib/stores/uploader.ts @@ -82,22 +82,29 @@ const createUploader = () => { n.files.unshift(newFile); return n; }); - const uploadedFile = await temporaryStorage(region, projectId).createFile({ - bucketId, - fileId: id ?? ID.unique(), - file, - permissions, - onProgress: (progress) => { - newFile.$id = progress.$id; - newFile.progress = progress.progress; - newFile.status = progress.progress === 100 ? 'success' : 'pending'; - updateFile(progress.$id, newFile); - } - }); - newFile.$id = uploadedFile.$id; - newFile.progress = 100; - newFile.status = 'success'; - updateFile(newFile.$id, newFile); + try { + const uploadedFile = await temporaryStorage(region, projectId).createFile({ + bucketId, + fileId: id ?? ID.unique(), + file, + permissions, + onProgress: (progress) => { + newFile.$id = progress.$id; + newFile.progress = progress.progress; + newFile.status = progress.progress === 100 ? 'success' : 'pending'; + updateFile(progress.$id, newFile); + } + }); + newFile.$id = uploadedFile.$id; + newFile.progress = 100; + newFile.status = 'success'; + updateFile(newFile.$id, newFile); + } catch (e) { + newFile.status = 'failed'; + newFile.error = e?.message ?? 'Upload failed'; + updateFile(newFile.$id, newFile); + throw e; + } }; const uploadFiles = async ( diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte index e17db77997..e0d6f5a45f 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte @@ -105,9 +105,30 @@ isDragging = false; if (!event.dataTransfer?.files?.length) return; + const allowedExtensions: string[] = data.bucket.allowedFileExtensions ?? []; const droppedFiles = Array.from(event.dataTransfer.files); - const count = droppedFiles.length; + const validFiles: File[] = []; + const rejectedFiles: File[] = []; + for (const file of droppedFiles) { + const ext = file.name.split('.').pop()?.toLowerCase(); + if (allowedExtensions.length && (!ext || !allowedExtensions.includes(ext))) { + rejectedFiles.push(file); + } else { + validFiles.push(file); + } + } + + if (rejectedFiles.length) { + addNotification({ + type: 'error', + message: `${rejectedFiles.length} file(s) rejected — only ${allowedExtensions.join(', ')} allowed` + }); + } + + if (!validFiles.length) return; + + const count = validFiles.length; addNotification({ type: 'success', message: count === 1 ? 'File upload in progress' : `${count} file uploads in progress` @@ -115,18 +136,25 @@ trackEvent(Submit.FileCreate, { customId: false }); - const filesToUpload = droppedFiles.map((file) => ({ + const filesToUpload = validFiles.map((file) => ({ id: ID.unique(), file })); - await uploader.uploadFiles( + const results = await uploader.uploadFiles( page.params.region, page.params.project, page.params.bucket, filesToUpload, [] ); + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length) { + addNotification({ + type: 'error', + message: `${failures.length} file(s) failed to upload` + }); + } invalidate(Dependencies.FILES); } diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte index ae3ac85910..f12bf39260 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte @@ -69,7 +69,14 @@ trackEvent(Submit.FileCreate, { customId: !!id }); - await promise; + const results = await promise; + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length) { + addNotification({ + type: 'error', + message: `${failures.length} file(s) failed to upload` + }); + } invalidate(Dependencies.FILES); } catch (e) { addNotification({ From da81ffbddba2d7c6ff8cb8ca5ef217e8c5e3ce42 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 9 Apr 2026 12:26:00 +0100 Subject: [PATCH 3/4] refactor: move MAX_CONCURRENT_UPLOADS inside createUploader scope --- src/lib/stores/uploader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/uploader.ts b/src/lib/stores/uploader.ts index efc16f2559..91adbef41b 100644 --- a/src/lib/stores/uploader.ts +++ b/src/lib/stores/uploader.ts @@ -39,8 +39,6 @@ const temporaryFunctions = (region: string, projectId: string) => { return new Functions(clientProject); }; -const MAX_CONCURRENT_UPLOADS = 5; - const createUploader = () => { const { subscribe, set, update } = writable({ isOpen: false, @@ -107,6 +105,8 @@ const createUploader = () => { } }; + const MAX_CONCURRENT_UPLOADS = 5; + const uploadFiles = async ( region: string, projectId: string, From a9187aa34c3282a63c398538506787f4417e8330 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 10 Apr 2026 06:11:49 +0100 Subject: [PATCH 4/4] Revert "refactor: move MAX_CONCURRENT_UPLOADS inside createUploader scope" This reverts commit da81ffbddba2d7c6ff8cb8ca5ef217e8c5e3ce42. --- src/lib/stores/uploader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/uploader.ts b/src/lib/stores/uploader.ts index 91adbef41b..efc16f2559 100644 --- a/src/lib/stores/uploader.ts +++ b/src/lib/stores/uploader.ts @@ -39,6 +39,8 @@ const temporaryFunctions = (region: string, projectId: string) => { return new Functions(clientProject); }; +const MAX_CONCURRENT_UPLOADS = 5; + const createUploader = () => { const { subscribe, set, update } = writable({ isOpen: false, @@ -105,8 +107,6 @@ const createUploader = () => { } }; - const MAX_CONCURRENT_UPLOADS = 5; - const uploadFiles = async ( region: string, projectId: string,