11/**
2- * useUploadProgress — Issue #529
2+ * useUploadProgress
33 *
4- * Custom hook that uploads files via XMLHttpRequest so that
5- * onprogress events are available for per-file progress bars.
6- * Returns progress state, upload function, and a cancel function.
4+ * XHR-based multi-file upload hook with:
5+ * - Per-file progress bars (0–100 %)
6+ * - Concurrency cap (default 3 simultaneous uploads)
7+ * - Per-file cancel via cancelFile(name)
8+ * - Cancel-all via cancelAll()
79 */
810
11+ "use client" ;
12+
913import { useState , useCallback , useRef } from "react" ;
14+ import { UPLOAD_CONCURRENCY } from "@/app/lib/constants" ;
15+
16+ // ─── Types ────────────────────────────────────────────────────────────────────
1017
11- /** Tracking container object representing progress metrics for individual files */
18+ /** Per-file progress state */
1219export type FileProgress = {
13- /** 0–100 completion percentage indicator */
20+ /** Upload completion percentage 0–100 */
1421 percent : number ;
15- /** State machine status string tracking execution phases */
22+ /** Lifecycle status of this file's upload */
1623 status : "idle" | "uploading" | "done" | "error" | "cancelled" ;
17- /** Optional failure details populating on errors */
24+ /** Human-readable error message when status === "error" */
1825 error ?: string ;
1926} ;
2027
21- /** Metadata payload mapping information returned on successful ingest */
28+ /** Metadata returned on a successful upload */
2229export type UploadResult = {
23- /** The server-side async tracking ID associated with processing the artifact */
30+ /** Server-assigned job ID for tracking processing status */
2431 jobId : string ;
25- /** The baseline name string matching the origin payload */
32+ /** Original filename */
2633 name : string ;
27- /** The remote asset destination access endpoint URL */
34+ /** Remote URL of the stored file */
2835 url : string ;
2936} ;
3037
31- /**
32- * Custom hook handling safe parallel multi-file file streaming contexts using underlying XHR event channels.
33- *
34- * @returns State properties, status maps, and asynchronous invocation tools for batch execution and manual abort overrides.
35- */
36- export function useUploadProgress ( ) {
38+ // ─── Hook ─────────────────────────────────────────────────────────────────────
39+
40+ export function useUploadProgress ( concurrency = UPLOAD_CONCURRENCY ) {
3741 const [ progresses , setProgresses ] = useState < Record < string , FileProgress > > ( { } ) ;
3842 const [ results , setResults ] = useState < UploadResult [ ] > ( [ ] ) ;
3943 const [ isUploading , setIsUploading ] = useState ( false ) ;
4044
41- // Keep a ref to all active XHRs so we can abort them
42- const xhrRefs = useRef < XMLHttpRequest [ ] > ( [ ] ) ;
45+ // Map from file name → active XHR (so individual files can be cancelled)
46+ const xhrMap = useRef < Map < string , XMLHttpRequest > > ( new Map ( ) ) ;
47+
48+ // ── Helpers ────────────────────────────────────────────────────────────────
4349
44- /**
45- * Dispatches micro-mutations matching granular progress shifts per file index.
46- */
4750 const setFileProgress = useCallback (
48- ( fileName : string , update : Partial < FileProgress > ) => {
51+ ( name : string , update : Partial < FileProgress > ) => {
4952 setProgresses ( ( prev ) => ( {
5053 ...prev ,
51- [ fileName ] : { ...prev [ fileName ] , ...update } ,
54+ [ name ] : { ...prev [ name ] , ...update } ,
5255 } ) ) ;
5356 } ,
54- [ ]
57+ [ ] ,
5558 ) ;
5659
57- /**
58- * Handles low-level XHR transport initialization, header bundling, and streaming callbacks for an explicit payload.
59- * * @param file - The distinct binary file target instance requested for transmission.
60- * @returns Resolves to a structural report object containing tracking metadata upon ingest completion.
61- */
60+ // ── Single-file upload ─────────────────────────────────────────────────────
61+
6262 const uploadFile = useCallback (
6363 ( file : File ) : Promise < UploadResult > => {
6464 return new Promise ( ( resolve , reject ) => {
6565 const xhr = new XMLHttpRequest ( ) ;
66- xhrRefs . current . push ( xhr ) ;
66+ xhrMap . current . set ( file . name , xhr ) ;
6767
6868 const formData = new FormData ( ) ;
6969 formData . append ( "files" , file ) ;
@@ -78,83 +78,122 @@ export function useUploadProgress() {
7878 } ;
7979
8080 xhr . onload = ( ) => {
81+ xhrMap . current . delete ( file . name ) ;
82+
8183 if ( xhr . status >= 200 && xhr . status < 300 ) {
8284 try {
8385 const data = JSON . parse ( xhr . responseText ) ;
86+ // API wraps in { data: { jobId, files: [...] }, error: null }
87+ const payload = data . data ?? data ;
88+ const jobId : string =
89+ payload . jobId ?? payload . files ?. [ 0 ] ?. jobId ?? "" ;
90+ const url : string = payload . files ?. [ 0 ] ?. url ?? "" ;
91+
8492 setFileProgress ( file . name , { percent : 100 , status : "done" } ) ;
85- resolve ( {
86- jobId : data . jobId ?? data . files ?. [ 0 ] ?. jobId ?? "" ,
87- name : file . name ,
88- url : data . files ?. [ 0 ] ?. url ?? "" ,
89- } ) ;
93+ resolve ( { jobId, name : file . name , url } ) ;
9094 } catch {
91- setFileProgress ( file . name , { status : "error" , error : "Invalid server response" } ) ;
92- reject ( new Error ( "Invalid server response" ) ) ;
95+ const msg = "Invalid server response" ;
96+ setFileProgress ( file . name , { status : "error" , error : msg } ) ;
97+ reject ( new Error ( msg ) ) ;
9398 }
9499 } else {
95- const msg = `Upload failed (HTTP ${ xhr . status } )` ;
100+ let msg = `Upload failed (HTTP ${ xhr . status } )` ;
101+ try {
102+ const body = JSON . parse ( xhr . responseText ) ;
103+ if ( body ?. error ) msg = body . error ;
104+ else if ( body ?. data ?. error ) msg = body . data . error ;
105+ } catch { /* ignore parse error */ }
96106 setFileProgress ( file . name , { status : "error" , error : msg } ) ;
97107 reject ( new Error ( msg ) ) ;
98108 }
99109 } ;
100110
101111 xhr . onerror = ( ) => {
112+ xhrMap . current . delete ( file . name ) ;
102113 const msg = "Network error during upload" ;
103114 setFileProgress ( file . name , { status : "error" , error : msg } ) ;
104115 reject ( new Error ( msg ) ) ;
105116 } ;
106117
107118 xhr . onabort = ( ) => {
119+ xhrMap . current . delete ( file . name ) ;
108120 setFileProgress ( file . name , { status : "cancelled" } ) ;
109121 reject ( new DOMException ( "Upload cancelled" , "AbortError" ) ) ;
110122 } ;
111123
124+ setFileProgress ( file . name , { percent : 0 , status : "uploading" } ) ;
112125 xhr . send ( formData ) ;
113126 } ) ;
114127 } ,
115- [ setFileProgress ]
128+ [ setFileProgress ] ,
116129 ) ;
117130
131+ // ── Concurrency-limited queue ──────────────────────────────────────────────
132+
118133 /**
119- * Orchestrates parallel batch processing pipelines across variable file array configurations.
120- * * @param files - List of File targets slated for transit dispatching.
121- * @returns Structured collection summarizing successfully loaded files and their active job associations.
134+ * Upload `files` with at most `concurrency` simultaneous XHRs.
135+ * Returns the list of successful UploadResults.
122136 */
123137 const upload = useCallback (
124- async ( files : File [ ] ) => {
125- if ( files . length === 0 ) return ;
138+ async ( files : File [ ] ) : Promise < UploadResult [ ] > => {
139+ if ( files . length === 0 ) return [ ] ;
126140
127141 // Reset state
128- xhrRefs . current = [ ] ;
142+ xhrMap . current = new Map ( ) ;
129143 const initProgress : Record < string , FileProgress > = { } ;
130- files . forEach ( ( f ) => ( initProgress [ f . name ] = { percent : 0 , status : "idle" } ) ) ;
144+ files . forEach ( ( f ) => {
145+ initProgress [ f . name ] = { percent : 0 , status : "idle" } ;
146+ } ) ;
131147 setProgresses ( initProgress ) ;
132148 setResults ( [ ] ) ;
133149 setIsUploading ( true ) ;
134150
151+ const successful : UploadResult [ ] = [ ] ;
152+
135153 try {
136- const uploadResults = await Promise . allSettled ( files . map ( uploadFile ) ) ;
137- const successful : UploadResult [ ] = [ ] ;
138- uploadResults . forEach ( ( r ) => {
139- if ( r . status === "fulfilled" ) successful . push ( r . value ) ;
140- } ) ;
154+ // Run the queue with a fixed concurrency slot pool
155+ let index = 0 ;
156+
157+ const worker = async ( ) => {
158+ while ( index < files . length ) {
159+ const current = files [ index ++ ] ;
160+ const result = await uploadFile ( current ) . catch ( ( ) => null ) ;
161+ if ( result ) successful . push ( result ) ;
162+ }
163+ } ;
164+
165+ const cap = Math . min ( concurrency , files . length ) ;
166+ await Promise . all ( Array . from ( { length : cap } , worker ) ) ;
167+
141168 setResults ( successful ) ;
142169 return successful ;
143170 } finally {
144171 setIsUploading ( false ) ;
145- xhrRefs . current = [ ] ;
172+ xhrMap . current . clear ( ) ;
146173 }
147174 } ,
148- [ uploadFile ]
175+ [ uploadFile , concurrency ] ,
149176 ) ;
150177
151- /**
152- * Iterates through active XHR connections to force instant abort events.
153- */
178+ // ── Cancel helpers ─────────────────────────────────────────────────────────
179+
180+ /** Abort a single in-flight upload by filename */
181+ const cancelFile = useCallback ( ( name : string ) => {
182+ const xhr = xhrMap . current . get ( name ) ;
183+ if ( xhr ) {
184+ xhr . abort ( ) ;
185+ xhrMap . current . delete ( name ) ;
186+ } else {
187+ // File may be queued but not yet uploading — mark cancelled proactively
188+ setFileProgress ( name , { status : "cancelled" } ) ;
189+ }
190+ } , [ setFileProgress ] ) ;
191+
192+ /** Abort all in-flight uploads */
154193 const cancelAll = useCallback ( ( ) => {
155- xhrRefs . current . forEach ( ( xhr ) => xhr . abort ( ) ) ;
156- xhrRefs . current = [ ] ;
194+ xhrMap . current . forEach ( ( xhr ) => xhr . abort ( ) ) ;
195+ xhrMap . current . clear ( ) ;
157196 } , [ ] ) ;
158197
159- return { progresses, results, isUploading, upload, cancelAll } ;
198+ return { progresses, results, isUploading, upload, cancelFile , cancelAll } ;
160199}
0 commit comments