file-uploader.js
3 months ago
header-tabs.css
3 months ago
search-reulsts.js
3 months ago
tabs.css
3 months ago
tabs.js
3 months ago
tailwind.css
3 months ago
file-uploader.js
340 lines
| 1 | /* eslint-disable no-alert */ |
| 2 | class FileUploader { |
| 3 | /** |
| 4 | * @param {HTMLElement} wrap Wrap element |
| 5 | * @param {HTMLInputElement} inputEl File input element (carries data-* config) |
| 6 | */ |
| 7 | constructor( wrap, inputEl ) { |
| 8 | this.wrap = wrap; |
| 9 | this.input = inputEl; |
| 10 | this.ui = { |
| 11 | dropZone: wrap.querySelector( '.dropzone' ), |
| 12 | }; |
| 13 | |
| 14 | // ── Read limits from data-attributes on the input ──────────── |
| 15 | this.maxSizeMB = parseFloat( inputEl.dataset.maxSizeMb ) || 5; |
| 16 | this.maxFiles = parseInt( inputEl.dataset.maxFiles ) || 10; |
| 17 | this.allowedExt = ( |
| 18 | inputEl.dataset.allowedExt || 'jpg,jpeg,png,gif,svg,pdf,docx,txt' |
| 19 | ) |
| 20 | .split( ',' ) |
| 21 | .map( ( e ) => e.trim().toLowerCase() ); |
| 22 | // ───────────────────────────────────────────────────────────── |
| 23 | |
| 24 | this.maxSizeBytes = this.maxSizeMB * 1024 * 1024; |
| 25 | |
| 26 | // Internal state |
| 27 | this.selectedFiles = []; |
| 28 | |
| 29 | // Setup |
| 30 | this.input.accept = this.allowedExt.map( ( e ) => '.' + e ).join( ',' ); |
| 31 | this._createRegions(); |
| 32 | this._renderHints(); |
| 33 | this._bindEvents(); |
| 34 | } |
| 35 | |
| 36 | reset() { |
| 37 | this.selectedFiles = []; |
| 38 | this._renderSelected(); |
| 39 | } |
| 40 | |
| 41 | // ── Event binding ───────────────────────────────────────────── |
| 42 | |
| 43 | _bindEvents() { |
| 44 | // Native file picker |
| 45 | this.input.addEventListener( 'change', ( e ) => { |
| 46 | this._processFiles( e.target.files ); |
| 47 | this.input.value = ''; // reset so same file can be re-selected |
| 48 | } ); |
| 49 | |
| 50 | // Drag & drop |
| 51 | this.ui.dropZone.addEventListener( 'dragover', ( e ) => { |
| 52 | e.preventDefault(); |
| 53 | if ( ! this.ui.dropZone.classList.contains( 'limit-reached' ) ) { |
| 54 | this.ui.dropZone.classList.add( 'dragover' ); |
| 55 | } |
| 56 | } ); |
| 57 | this.ui.dropZone.addEventListener( 'dragleave', () => |
| 58 | this.ui.dropZone.classList.remove( 'dragover' ) |
| 59 | ); |
| 60 | this.ui.dropZone.addEventListener( 'drop', ( e ) => { |
| 61 | e.preventDefault(); |
| 62 | this.ui.dropZone.classList.remove( 'dragover' ); |
| 63 | this._processFiles( e.dataTransfer.files ); |
| 64 | } ); |
| 65 | } |
| 66 | |
| 67 | // ── UI Rendering ────────────────────────────────────────────── |
| 68 | |
| 69 | _createRegions() { |
| 70 | // Count badge |
| 71 | const countBadge = document.createElement( 'div' ); |
| 72 | countBadge.className = 'file-count-badge'; |
| 73 | countBadge.style.display = 'none'; |
| 74 | this.wrap.appendChild( countBadge ); |
| 75 | this.ui.countBadge = countBadge; |
| 76 | |
| 77 | // Error box |
| 78 | const errorBox = document.createElement( 'div' ); |
| 79 | errorBox.className = 'error-box'; |
| 80 | errorBox.style.display = 'none'; |
| 81 | this.wrap.appendChild( errorBox ); |
| 82 | this.ui.errorBox = errorBox; |
| 83 | |
| 84 | // Error box title |
| 85 | const errorBoxTitle = document.createElement( 'p' ); |
| 86 | errorBoxTitle.className = 'error-box-title'; |
| 87 | errorBoxTitle.innerHTML = 'Some files were rejected:'; |
| 88 | errorBox.appendChild( errorBoxTitle ); |
| 89 | |
| 90 | // Error list |
| 91 | const errorList = document.createElement( 'ul' ); |
| 92 | errorList.className = 'error-list'; |
| 93 | errorBox.appendChild( errorList ); |
| 94 | this.ui.errorList = errorList; |
| 95 | |
| 96 | // Selected container |
| 97 | const selectedContainer = document.createElement( 'div' ); |
| 98 | selectedContainer.className = 'selected-files-container'; |
| 99 | this.wrap.appendChild( selectedContainer ); |
| 100 | this.ui.selectedContainer = selectedContainer; |
| 101 | } |
| 102 | |
| 103 | _renderHints() { |
| 104 | // append div with class upload-hints |
| 105 | const hints = document.createElement( 'div' ); |
| 106 | hints.className = 'upload-hints'; |
| 107 | hints.innerHTML = |
| 108 | `<div><strong>Allowed:</strong> ${ this.allowedExt |
| 109 | .map( ( e ) => '.' + e ) |
| 110 | .join( ', ' ) }</div>` + |
| 111 | `<span><strong>Max size:</strong> ${ this.maxSizeMB } MB per file</span>` + |
| 112 | `<span><strong>Max files:</strong> ${ this.maxFiles }</span>`; |
| 113 | |
| 114 | this.ui.dropZone.appendChild( hints ); |
| 115 | } |
| 116 | |
| 117 | _updateCountBadge() { |
| 118 | const total = this.selectedFiles.length; |
| 119 | const badge = this.ui.countBadge; |
| 120 | |
| 121 | if ( total === 0 ) { |
| 122 | badge.style.display = 'none'; |
| 123 | return; |
| 124 | } |
| 125 | |
| 126 | badge.style.display = 'inline-block'; |
| 127 | badge.textContent = `${ total } / ${ this.maxFiles } file${ |
| 128 | total !== 1 ? 's' : '' |
| 129 | } selected`; |
| 130 | badge.classList.toggle( 'at-limit', total >= this.maxFiles ); |
| 131 | |
| 132 | // Visually lock drop zone at limit |
| 133 | this.ui.dropZone.classList.toggle( |
| 134 | 'limit-reached', |
| 135 | total >= this.maxFiles |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | _showErrors( rejections ) { |
| 140 | const { errorBox, errorList } = this.ui; |
| 141 | if ( rejections.length === 0 ) { |
| 142 | errorBox.style.display = 'none'; |
| 143 | return; |
| 144 | } |
| 145 | errorList.innerHTML = rejections |
| 146 | .map( |
| 147 | ( r ) => |
| 148 | `<li><strong>${ r.name }</strong>: ${ r.reasons.join( |
| 149 | '; ' |
| 150 | ) }</li>` |
| 151 | ) |
| 152 | .join( '' ); |
| 153 | errorBox.style.display = 'block'; |
| 154 | } |
| 155 | |
| 156 | _createFileItem( data ) { |
| 157 | const { id, filename, fileimage, datetime, filesize } = data; |
| 158 | |
| 159 | const box = document.createElement( 'div' ); |
| 160 | box.className = 'file-item'; |
| 161 | box.dataset.id = id; |
| 162 | |
| 163 | // Thumbnail or generic icon |
| 164 | const imgDiv = document.createElement( 'div' ); |
| 165 | imgDiv.className = 'file-image'; |
| 166 | if ( this._isImage( filename ) ) { |
| 167 | const img = document.createElement( 'img' ); |
| 168 | img.src = fileimage; |
| 169 | img.alt = ''; |
| 170 | imgDiv.appendChild( img ); |
| 171 | } else { |
| 172 | imgDiv.innerHTML = '<i class="far fa-file-alt"></i>'; |
| 173 | } |
| 174 | |
| 175 | // File metadata |
| 176 | const detail = document.createElement( 'div' ); |
| 177 | detail.className = 'file-detail'; |
| 178 | detail.innerHTML = ` |
| 179 | <h6>${ filename }</h6> |
| 180 | <p> |
| 181 | <span>Size: ${ filesize }</span> |
| 182 | <span style="margin-left:10px">Modified: ${ datetime }</span> |
| 183 | </p> |
| 184 | `; |
| 185 | |
| 186 | // Action buttons |
| 187 | const actions = document.createElement( 'div' ); |
| 188 | actions.className = 'file-actions'; |
| 189 | |
| 190 | const deleteBtn = document.createElement( 'button' ); |
| 191 | deleteBtn.className = 'file-action-btn'; |
| 192 | deleteBtn.textContent = 'Delete'; |
| 193 | deleteBtn.addEventListener( 'click', () => this._deleteSelected( id ) ); |
| 194 | actions.appendChild( deleteBtn ); |
| 195 | detail.appendChild( actions ); |
| 196 | box.appendChild( imgDiv ); |
| 197 | box.appendChild( detail ); |
| 198 | return box; |
| 199 | } |
| 200 | |
| 201 | _renderSelected() { |
| 202 | this.ui.selectedContainer.innerHTML = ''; |
| 203 | this.selectedFiles.forEach( ( f ) => |
| 204 | this.ui.selectedContainer.appendChild( this._createFileItem( f ) ) |
| 205 | ); |
| 206 | this._updateCountBadge(); |
| 207 | } |
| 208 | |
| 209 | // ── State mutations ─────────────────────────────────────────── |
| 210 | |
| 211 | _deleteSelected( id ) { |
| 212 | if ( |
| 213 | globalThis.confirm( 'Are you sure you want to delete this file?' ) |
| 214 | ) { |
| 215 | this.selectedFiles = this.selectedFiles.filter( |
| 216 | ( f ) => f.id !== id |
| 217 | ); |
| 218 | this._renderSelected(); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | // ── Core: process a FileList ────────────────────────────────── |
| 223 | |
| 224 | _processFiles( fileList ) { |
| 225 | const rejections = []; |
| 226 | const slots = this.maxFiles - this.selectedFiles.length; |
| 227 | |
| 228 | // Already at limit before even looking at new files |
| 229 | if ( slots <= 0 ) { |
| 230 | this._showErrors( [ |
| 231 | { |
| 232 | name: 'Selection blocked', |
| 233 | reasons: [ |
| 234 | `you have already reached the limit of ${ this.maxFiles } files`, |
| 235 | ], |
| 236 | }, |
| 237 | ] ); |
| 238 | return; |
| 239 | } |
| 240 | |
| 241 | let accepted = 0; |
| 242 | |
| 243 | for ( const file of fileList ) { |
| 244 | // Enforce count limit across this batch |
| 245 | if ( accepted >= slots ) { |
| 246 | rejections.push( { |
| 247 | name: file.name, |
| 248 | reasons: [ `max file limit of ${ this.maxFiles } reached` ], |
| 249 | } ); |
| 250 | continue; |
| 251 | } |
| 252 | |
| 253 | // Validate extension & size |
| 254 | const errors = this._validateFile( file ); |
| 255 | if ( errors.length > 0 ) { |
| 256 | rejections.push( { name: file.name, reasons: errors } ); |
| 257 | continue; |
| 258 | } |
| 259 | |
| 260 | // All good — read as data URL |
| 261 | const reader = new globalThis.FileReader(); |
| 262 | reader.onloadend = () => { |
| 263 | this.selectedFiles.push( { |
| 264 | id: this._generateId(), |
| 265 | filename: file.name, |
| 266 | filetype: file.type, |
| 267 | fileimage: reader.result, |
| 268 | datetime: file.lastModified |
| 269 | ? new Date( file.lastModified ).toLocaleString( |
| 270 | 'en-IN' |
| 271 | ) |
| 272 | : 'Unknown', |
| 273 | filesize: this._formatSize( file.size ), |
| 274 | } ); |
| 275 | this._renderSelected(); |
| 276 | }; |
| 277 | reader.readAsDataURL( file ); |
| 278 | accepted++; |
| 279 | } |
| 280 | |
| 281 | this._showErrors( rejections ); |
| 282 | } |
| 283 | |
| 284 | // ── Utilities ───────────────────────────────────────────────── |
| 285 | |
| 286 | _generateId() { |
| 287 | return ( |
| 288 | Math.random().toString( 36 ).slice( 2, 9 ) + |
| 289 | Date.now().toString( 36 ) |
| 290 | ); |
| 291 | } |
| 292 | |
| 293 | _formatSize( bytes, decimals = 2 ) { |
| 294 | if ( bytes === 0 ) { |
| 295 | return '0 Bytes'; |
| 296 | } |
| 297 | const k = 1024; |
| 298 | const sizes = [ 'Bytes', 'KB', 'MB', 'GB', 'TB' ]; |
| 299 | const i = Math.floor( Math.log( bytes ) / Math.log( k ) ); |
| 300 | return ( |
| 301 | parseFloat( |
| 302 | ( bytes / Math.pow( k, i ) ).toFixed( Math.max( 0, decimals ) ) |
| 303 | ) + |
| 304 | ' ' + |
| 305 | sizes[ i ] |
| 306 | ); |
| 307 | } |
| 308 | |
| 309 | _isImage( filename ) { |
| 310 | return /\.(jpg|jpeg|png|gif|svg)$/i.test( filename ); |
| 311 | } |
| 312 | |
| 313 | _getExt( filename ) { |
| 314 | return filename.split( '.' ).pop().toLowerCase(); |
| 315 | } |
| 316 | |
| 317 | // ── Validation ──────────────────────────────────────────────── |
| 318 | |
| 319 | _validateFile( file ) { |
| 320 | const errors = []; |
| 321 | const ext = this._getExt( file.name ); |
| 322 | |
| 323 | if ( ! this.allowedExt.includes( ext ) ) { |
| 324 | errors.push( `".${ ext }" is not an allowed extension` ); |
| 325 | } |
| 326 | |
| 327 | if ( file.size > this.maxSizeBytes ) { |
| 328 | errors.push( |
| 329 | `exceeds max size of ${ |
| 330 | this.maxSizeMB |
| 331 | } MB (is ${ this._formatSize( file.size ) })` |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | return errors; |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | export { FileUploader }; |
| 340 |