@liwe3/webcomponents 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/AITextEditor.d.ts +173 -0
  2. package/dist/AITextEditor.d.ts.map +1 -0
  3. package/dist/ChunkUploader.d.ts +103 -0
  4. package/dist/ChunkUploader.d.ts.map +1 -0
  5. package/dist/ChunkUploader.js +614 -0
  6. package/dist/ChunkUploader.js.map +1 -0
  7. package/dist/ContainerBox.d.ts +112 -0
  8. package/dist/ContainerBox.d.ts.map +1 -0
  9. package/dist/ContainerBox.js +359 -0
  10. package/dist/ContainerBox.js.map +1 -0
  11. package/dist/DateSelector.d.ts +103 -0
  12. package/dist/DateSelector.d.ts.map +1 -0
  13. package/dist/DateSelector.js +372 -0
  14. package/dist/DateSelector.js.map +1 -0
  15. package/dist/Drawer.d.ts +63 -0
  16. package/dist/Drawer.d.ts.map +1 -0
  17. package/dist/Drawer.js +340 -0
  18. package/dist/Drawer.js.map +1 -0
  19. package/dist/ImageView.d.ts +42 -0
  20. package/dist/ImageView.d.ts.map +1 -0
  21. package/dist/ImageView.js +209 -0
  22. package/dist/ImageView.js.map +1 -0
  23. package/dist/PopoverMenu.d.ts +103 -0
  24. package/dist/PopoverMenu.d.ts.map +1 -0
  25. package/dist/PopoverMenu.js +312 -0
  26. package/dist/PopoverMenu.js.map +1 -0
  27. package/dist/SmartSelect.d.ts +99 -0
  28. package/dist/SmartSelect.d.ts.map +1 -0
  29. package/dist/SmartSelect.js.map +1 -1
  30. package/dist/Toast.d.ts +127 -0
  31. package/dist/Toast.d.ts.map +1 -0
  32. package/dist/Toast.js +507 -0
  33. package/dist/Toast.js.map +1 -0
  34. package/dist/TreeView.d.ts +84 -0
  35. package/dist/TreeView.d.ts.map +1 -0
  36. package/dist/TreeView.js +478 -0
  37. package/dist/TreeView.js.map +1 -0
  38. package/dist/index.d.ts +16 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +30 -6
  41. package/dist/index.js.map +1 -1
  42. package/package.json +43 -3
  43. package/src/ChunkUploader.ts +921 -0
  44. package/src/ContainerBox.ts +570 -0
  45. package/src/DateSelector.ts +550 -0
  46. package/src/Drawer.ts +435 -0
  47. package/src/ImageView.ts +265 -0
  48. package/src/PopoverMenu.ts +595 -0
  49. package/src/SmartSelect.ts +231 -231
  50. package/src/Toast.ts +834 -0
  51. package/src/TreeView.ts +673 -0
  52. package/src/index.ts +70 -3
@@ -0,0 +1,921 @@
1
+ /**
2
+ * ChunkUploader Web Component
3
+ * A file uploader with drag & drop, file previews, and chunked upload support
4
+ */
5
+
6
+ export interface UploadedFile {
7
+ id: string;
8
+ file: File;
9
+ status: 'pending' | 'uploading' | 'completed' | 'error' | 'aborted';
10
+ progress: number;
11
+ uploadedBytes: number;
12
+ preview?: string;
13
+ error?: string;
14
+ uploadId?: string; // R2 upload ID for aborting
15
+ key?: string; // R2 key for aborting
16
+ }
17
+
18
+ export interface ChunkUploaderConfig {
19
+ serverURL: string;
20
+ chunkSize: number; // in MB
21
+ authToken?: string;
22
+ validFiletypes?: string[]; // Array of extensions like ['jpg', 'png', 'pdf']
23
+ maxFileSize?: number; // in MB
24
+ onfilecomplete?: ( file: UploadedFile ) => void;
25
+ onuploadcomplete?: ( files: UploadedFile[] ) => void;
26
+ }
27
+
28
+ const DEFAULT_CHUNK_SIZE = 5; // 5MB - R2/S3 minimum part size (except last part)
29
+ const DEFAULT_MAX_FILE_SIZE = 5120; // 5GB in MB
30
+
31
+ export class ChunkUploaderElement extends HTMLElement {
32
+ declare shadowRoot: ShadowRoot;
33
+ private files: Map<string, UploadedFile> = new Map();
34
+ private config: ChunkUploaderConfig = {
35
+ serverURL: '',
36
+ chunkSize: DEFAULT_CHUNK_SIZE,
37
+ maxFileSize: DEFAULT_MAX_FILE_SIZE
38
+ };
39
+ private isUploading = false;
40
+ private abortController: AbortController | null = null;
41
+
42
+ constructor () {
43
+ super();
44
+ this.attachShadow( { mode: 'open' } );
45
+ this.render();
46
+ this.bindEvents();
47
+ }
48
+
49
+ static get observedAttributes (): string[] {
50
+ return [ 'server-url', 'chunk-size', 'auth-token', 'valid-filetypes', 'max-file-size' ];
51
+ }
52
+
53
+ attributeChangedCallback ( name: string, oldValue: string | null, newValue: string | null ): void {
54
+ if ( oldValue !== newValue ) {
55
+ switch ( name ) {
56
+ case 'server-url':
57
+ this.config.serverURL = newValue || '';
58
+ break;
59
+ case 'chunk-size':
60
+ this.config.chunkSize = parseFloat( newValue || String( DEFAULT_CHUNK_SIZE ) );
61
+ break;
62
+ case 'auth-token':
63
+ this.config.authToken = newValue || undefined;
64
+ break;
65
+ case 'valid-filetypes':
66
+ this.config.validFiletypes = newValue ? newValue.split( ',' ).map( ext => ext.trim() ) : undefined;
67
+ break;
68
+ case 'max-file-size':
69
+ this.config.maxFileSize = parseFloat( newValue || String( DEFAULT_MAX_FILE_SIZE ) );
70
+ break;
71
+ }
72
+ }
73
+ }
74
+
75
+ // Property getters and setters
76
+ get serverURL (): string {
77
+ return this.config.serverURL;
78
+ }
79
+
80
+ set serverURL ( value: string ) {
81
+ this.config.serverURL = value;
82
+ this.setAttribute( 'server-url', value );
83
+ }
84
+
85
+ get chunkSize (): number {
86
+ return this.config.chunkSize;
87
+ }
88
+
89
+ set chunkSize ( value: number ) {
90
+ this.config.chunkSize = value;
91
+ this.setAttribute( 'chunk-size', value.toString() );
92
+ }
93
+
94
+ get authToken (): string | undefined {
95
+ return this.config.authToken;
96
+ }
97
+
98
+ set authToken ( value: string | undefined ) {
99
+ if ( value ) {
100
+ this.config.authToken = value;
101
+ this.setAttribute( 'auth-token', value );
102
+ } else {
103
+ this.config.authToken = undefined;
104
+ this.removeAttribute( 'auth-token' );
105
+ }
106
+ }
107
+
108
+ get validFiletypes (): string[] | undefined {
109
+ return this.config.validFiletypes;
110
+ }
111
+
112
+ set validFiletypes ( value: string[] | undefined ) {
113
+ this.config.validFiletypes = value;
114
+ if ( value ) {
115
+ this.setAttribute( 'valid-filetypes', value.join( ',' ) );
116
+ } else {
117
+ this.removeAttribute( 'valid-filetypes' );
118
+ }
119
+ }
120
+
121
+ get maxFileSize (): number {
122
+ return this.config.maxFileSize || DEFAULT_MAX_FILE_SIZE;
123
+ }
124
+
125
+ set maxFileSize ( value: number ) {
126
+ this.config.maxFileSize = value;
127
+ this.setAttribute( 'max-file-size', value.toString() );
128
+ }
129
+
130
+ set onfilecomplete ( callback: ( ( file: UploadedFile ) => void ) | undefined ) {
131
+ this.config.onfilecomplete = callback;
132
+ }
133
+
134
+ set onuploadcomplete ( callback: ( ( files: UploadedFile[] ) => void ) | undefined ) {
135
+ this.config.onuploadcomplete = callback;
136
+ }
137
+
138
+ /**
139
+ * Formats bytes to human readable string
140
+ */
141
+ private formatBytes ( bytes: number ): string {
142
+ if ( bytes === 0 ) return '0 Bytes';
143
+ const k = 1024;
144
+ const sizes = [ 'Bytes', 'KB', 'MB', 'GB' ];
145
+ const i = Math.floor( Math.log( bytes ) / Math.log( k ) );
146
+ return Math.round( ( bytes / Math.pow( k, i ) ) * 100 ) / 100 + ' ' + sizes[ i ];
147
+ }
148
+
149
+ /**
150
+ * Generates a unique ID for a file
151
+ */
152
+ private generateFileId (): string {
153
+ return `file-${ Date.now() }-${ Math.random().toString( 36 ).substr( 2, 9 ) }`;
154
+ }
155
+
156
+ /**
157
+ * Validates a file based on config
158
+ */
159
+ private validateFile ( file: File ): { valid: boolean; error?: string } {
160
+ // Check file size
161
+ const maxSizeBytes = this.maxFileSize * 1024 * 1024;
162
+ if ( file.size > maxSizeBytes ) {
163
+ return {
164
+ valid: false,
165
+ error: `File size exceeds maximum of ${ this.formatBytes( maxSizeBytes ) }`
166
+ };
167
+ }
168
+
169
+ // Check file type
170
+ if ( this.config.validFiletypes && this.config.validFiletypes.length > 0 ) {
171
+ const extension = file.name.split( '.' ).pop()?.toLowerCase();
172
+ if ( ! extension || ! this.config.validFiletypes.includes( extension ) ) {
173
+ return {
174
+ valid: false,
175
+ error: `File type .${ extension } is not allowed. Valid types: ${ this.config.validFiletypes.join( ', ' ) }`
176
+ };
177
+ }
178
+ }
179
+
180
+ return { valid: true };
181
+ }
182
+
183
+ /**
184
+ * Generates a preview for image files
185
+ */
186
+ private async generatePreview ( file: File ): Promise<string | undefined> {
187
+ if ( ! file.type.startsWith( 'image/' ) ) return undefined;
188
+
189
+ return new Promise( ( resolve ) => {
190
+ const reader = new FileReader();
191
+ reader.onload = ( e ) => resolve( e.target?.result as string );
192
+ reader.onerror = () => resolve( undefined );
193
+ reader.readAsDataURL( file );
194
+ } );
195
+ }
196
+
197
+ /**
198
+ * Adds files to the upload queue
199
+ */
200
+ private async addFiles ( fileList: FileList ): Promise<void> {
201
+ for ( let i = 0; i < fileList.length; i++ ) {
202
+ const file = fileList[ i ];
203
+ const validation = this.validateFile( file );
204
+
205
+ const id = this.generateFileId();
206
+ const uploadedFile: UploadedFile = {
207
+ id,
208
+ file,
209
+ status: validation.valid ? 'pending' : 'error',
210
+ progress: 0,
211
+ uploadedBytes: 0,
212
+ error: validation.error
213
+ };
214
+
215
+ // Generate preview for images
216
+ if ( validation.valid && file.type.startsWith( 'image/' ) ) {
217
+ uploadedFile.preview = await this.generatePreview( file );
218
+ }
219
+
220
+ this.files.set( id, uploadedFile );
221
+ }
222
+
223
+ this.renderFileCards();
224
+ }
225
+
226
+ /**
227
+ * Removes a file from the queue
228
+ */
229
+ private removeFile ( id: string ): void {
230
+ this.files.delete( id );
231
+ this.renderFileCards();
232
+ }
233
+
234
+ /**
235
+ * Uploads a single file with chunking
236
+ */
237
+ private async uploadFile ( uploadedFile: UploadedFile ): Promise<void> {
238
+ if ( ! this.config.serverURL ) {
239
+ throw new Error( 'Server URL is not configured' );
240
+ }
241
+
242
+ const { file } = uploadedFile;
243
+ uploadedFile.status = 'uploading';
244
+ uploadedFile.progress = 0;
245
+ this.updateFileCard( uploadedFile.id );
246
+
247
+ try {
248
+ const headers: HeadersInit = {
249
+ 'Content-Type': 'application/json'
250
+ };
251
+
252
+ if ( this.config.authToken ) {
253
+ headers[ 'Authorization' ] = `Bearer ${ this.config.authToken }`;
254
+ }
255
+
256
+ // Step 1: Initiate multipart upload
257
+ const initResponse = await fetch( `${ this.config.serverURL }/api/upload/initiate`, {
258
+ method: 'POST',
259
+ headers,
260
+ body: JSON.stringify( {
261
+ fileName: file.name,
262
+ fileType: file.type
263
+ } )
264
+ } );
265
+
266
+ if ( ! initResponse.ok ) {
267
+ throw new Error( `Failed to initiate upload: ${ await initResponse.text() }` );
268
+ }
269
+
270
+ const { uploadId, key } = await initResponse.json();
271
+
272
+ // Store upload metadata for potential abort
273
+ uploadedFile.uploadId = uploadId;
274
+ uploadedFile.key = key;
275
+
276
+ // Step 2: Upload chunks
277
+ const chunkSizeBytes = this.config.chunkSize * 1024 * 1024;
278
+ const totalParts = Math.ceil( file.size / chunkSizeBytes );
279
+ const parts: Array<{ partNumber: number; etag: string }> = [];
280
+
281
+ for ( let partNumber = 1; partNumber <= totalParts; partNumber++ ) {
282
+ // Check if upload was aborted
283
+ if ( this.abortController?.signal.aborted ) {
284
+ throw new Error( 'Upload aborted by user' );
285
+ }
286
+
287
+ const start = ( partNumber - 1 ) * chunkSizeBytes;
288
+ const end = Math.min( start + chunkSizeBytes, file.size );
289
+ const chunk = file.slice( start, end );
290
+
291
+ // Create headers for this part upload
292
+ const partHeaders: HeadersInit = {
293
+ 'Content-Type': 'application/octet-stream',
294
+ 'X-Upload-Id': uploadId,
295
+ 'X-Key': key,
296
+ 'X-Part-Number': partNumber.toString()
297
+ };
298
+
299
+ if ( this.config.authToken ) {
300
+ partHeaders[ 'Authorization' ] = `Bearer ${ this.config.authToken }`;
301
+ }
302
+
303
+ const partResponse = await fetch( `${ this.config.serverURL }/api/upload/part`, {
304
+ method: 'POST',
305
+ headers: partHeaders,
306
+ body: chunk,
307
+ signal: this.abortController?.signal
308
+ } );
309
+
310
+ if ( ! partResponse.ok ) {
311
+ throw new Error( `Failed to upload part ${ partNumber }` );
312
+ }
313
+
314
+ const { etag } = await partResponse.json();
315
+ parts.push( { partNumber, etag } );
316
+
317
+ // Update progress
318
+ uploadedFile.uploadedBytes = end;
319
+ uploadedFile.progress = ( uploadedFile.uploadedBytes / file.size ) * 100;
320
+ this.updateFileCard( uploadedFile.id );
321
+ }
322
+
323
+ // Step 3: Complete multipart upload
324
+ const completeResponse = await fetch( `${ this.config.serverURL }/api/upload/complete`, {
325
+ method: 'POST',
326
+ headers,
327
+ body: JSON.stringify( {
328
+ uploadId,
329
+ key,
330
+ parts
331
+ } )
332
+ } );
333
+
334
+ if ( ! completeResponse.ok ) {
335
+ throw new Error( 'Failed to complete upload' );
336
+ }
337
+
338
+ uploadedFile.status = 'completed';
339
+ uploadedFile.progress = 100;
340
+ this.updateFileCard( uploadedFile.id );
341
+
342
+ // Dispatch file complete event
343
+ this.dispatchEvent( new CustomEvent( 'filecomplete', {
344
+ detail: uploadedFile,
345
+ bubbles: true,
346
+ composed: true
347
+ } ) );
348
+
349
+ // Call callback if provided
350
+ if ( this.config.onfilecomplete ) {
351
+ this.config.onfilecomplete( uploadedFile );
352
+ }
353
+
354
+ } catch ( error ) {
355
+ // Check if this is an abort error (from AbortController)
356
+ const isAbortError = error instanceof Error &&
357
+ ( error.name === 'AbortError' || error.message === 'Upload aborted by user' );
358
+
359
+ // Only set error status if not aborted
360
+ if ( ! isAbortError ) {
361
+ uploadedFile.status = 'error';
362
+ uploadedFile.error = error instanceof Error ? error.message : 'Unknown error';
363
+ this.updateFileCard( uploadedFile.id );
364
+ }
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Starts uploading all pending files
371
+ */
372
+ private async startUpload (): Promise<void> {
373
+ if ( this.isUploading ) return;
374
+
375
+ const pendingFiles = Array.from( this.files.values() ).filter( f => f.status === 'pending' );
376
+ if ( pendingFiles.length === 0 ) return;
377
+
378
+ this.isUploading = true;
379
+ this.abortController = new AbortController();
380
+
381
+ const uploadBtn = this.shadowRoot.querySelector( '#uploadBtn' ) as HTMLButtonElement;
382
+ const abortBtn = this.shadowRoot.querySelector( '#abortBtn' ) as HTMLButtonElement;
383
+
384
+ if ( uploadBtn ) {
385
+ uploadBtn.disabled = true;
386
+ uploadBtn.textContent = 'Uploading...';
387
+ }
388
+
389
+ if ( abortBtn ) {
390
+ abortBtn.style.display = 'inline-block';
391
+ }
392
+
393
+ try {
394
+ for ( const file of pendingFiles ) {
395
+ // Check if upload was aborted
396
+ if ( this.abortController.signal.aborted ) {
397
+ break;
398
+ }
399
+ await this.uploadFile( file );
400
+ }
401
+
402
+ // All uploads complete
403
+ this.dispatchEvent( new CustomEvent( 'uploadcomplete', {
404
+ detail: Array.from( this.files.values() ),
405
+ bubbles: true,
406
+ composed: true
407
+ } ) );
408
+
409
+ // Call callback if provided
410
+ if ( this.config.onuploadcomplete ) {
411
+ this.config.onuploadcomplete( Array.from( this.files.values() ) );
412
+ }
413
+
414
+ } catch ( error ) {
415
+ // Only log non-abort errors
416
+ if ( error instanceof Error && error.message !== 'Upload aborted by user' ) {
417
+ console.error( 'Upload error:', error );
418
+ }
419
+ } finally {
420
+ this.isUploading = false;
421
+ if ( uploadBtn ) {
422
+ uploadBtn.disabled = false;
423
+ uploadBtn.textContent = 'Upload Files';
424
+ }
425
+ if ( abortBtn ) {
426
+ abortBtn.style.display = 'none';
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Aborts all pending and uploading files
433
+ */
434
+ private async abortAllUploads (): Promise<void> {
435
+ // Trigger abort signal to stop ongoing fetch requests
436
+ if ( this.abortController ) {
437
+ this.abortController.abort();
438
+ }
439
+
440
+ const filesToAbort = Array.from( this.files.values() ).filter(
441
+ f => f.status === 'pending' || f.status === 'uploading'
442
+ );
443
+
444
+ if ( filesToAbort.length === 0 ) return;
445
+
446
+ const headers: HeadersInit = {
447
+ 'Content-Type': 'application/json'
448
+ };
449
+
450
+ if ( this.config.authToken ) {
451
+ headers[ 'Authorization' ] = `Bearer ${ this.config.authToken }`;
452
+ }
453
+
454
+ for ( const file of filesToAbort ) {
455
+ // If upload was initiated, abort it on the server
456
+ if ( file.uploadId && file.key ) {
457
+ try {
458
+ await fetch( `${ this.config.serverURL }/api/upload/abort`, {
459
+ method: 'POST',
460
+ headers,
461
+ body: JSON.stringify( {
462
+ uploadId: file.uploadId,
463
+ key: file.key
464
+ } )
465
+ } );
466
+ } catch ( error ) {
467
+ console.error( `Failed to abort upload for ${ file.file.name }:`, error );
468
+ }
469
+ }
470
+
471
+ // Update file status
472
+ file.status = 'aborted';
473
+ file.error = 'Upload aborted by user';
474
+ this.updateFileCard( file.id );
475
+ }
476
+
477
+ // Dispatch abort event
478
+ this.dispatchEvent( new CustomEvent( 'uploadaborted', {
479
+ detail: filesToAbort,
480
+ bubbles: true,
481
+ composed: true
482
+ } ) );
483
+
484
+ // Reset upload state
485
+ this.isUploading = false;
486
+ const uploadBtn = this.shadowRoot.querySelector( '#uploadBtn' ) as HTMLButtonElement;
487
+ const abortBtn = this.shadowRoot.querySelector( '#abortBtn' ) as HTMLButtonElement;
488
+
489
+ if ( uploadBtn ) {
490
+ uploadBtn.disabled = false;
491
+ uploadBtn.textContent = 'Upload Files';
492
+ }
493
+
494
+ if ( abortBtn ) {
495
+ abortBtn.style.display = 'none';
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Updates a single file card in the DOM
501
+ */
502
+ private updateFileCard ( fileId: string ): void {
503
+ const file = this.files.get( fileId );
504
+ if ( ! file ) return;
505
+
506
+ const card = this.shadowRoot.querySelector( `[data-file-id="${ fileId }"]` ) as HTMLElement;
507
+ if ( ! card ) return;
508
+
509
+ const progressBar = card.querySelector( '.progress-bar' ) as HTMLElement;
510
+ const progressText = card.querySelector( '.progress-text' ) as HTMLElement;
511
+ const statusDiv = card.querySelector( '.status' ) as HTMLElement;
512
+
513
+ if ( progressBar ) {
514
+ progressBar.style.width = `${ file.progress }%`;
515
+
516
+ // Color based on status
517
+ if ( file.status === 'completed' ) {
518
+ progressBar.style.backgroundColor = '#22c55e'; // green
519
+ } else if ( file.status === 'error' ) {
520
+ progressBar.style.backgroundColor = '#ef4444'; // red
521
+ } else if ( file.status === 'aborted' ) {
522
+ progressBar.style.backgroundColor = '#f59e0b'; // orange
523
+ } else {
524
+ progressBar.style.backgroundColor = 'var(--color-primary)';
525
+ }
526
+ }
527
+
528
+ if ( progressText ) {
529
+ progressText.textContent = `${ Math.round( file.progress ) }%`;
530
+ }
531
+
532
+ if ( statusDiv && file.error ) {
533
+ statusDiv.textContent = file.error;
534
+ statusDiv.style.display = 'block';
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Renders all file cards
540
+ */
541
+ private renderFileCards (): void {
542
+ const container = this.shadowRoot.querySelector( '#fileCardsContainer' );
543
+ if ( ! container ) return;
544
+
545
+ if ( this.files.size === 0 ) {
546
+ container.innerHTML = '';
547
+ return;
548
+ }
549
+
550
+ container.innerHTML = Array.from( this.files.values() ).map( file => `
551
+ <div class="file-card" data-file-id="${ file.id }">
552
+ <button class="remove-btn" data-file-id="${ file.id }">×</button>
553
+ ${ file.preview ? `<div class="preview"><img src="${ file.preview }" alt="Preview"></div>` : '<div class="preview no-preview">📄</div>' }
554
+ <div class="file-info">
555
+ <div class="file-name" title="${ file.file.name }">${ file.file.name }</div>
556
+ <div class="file-size">${ this.formatBytes( file.file.size ) }</div>
557
+ </div>
558
+ <div class="progress-container">
559
+ <div class="progress-bar-bg">
560
+ <div class="progress-bar" style="width: ${ file.progress }%; background-color: ${ file.status === 'completed' ? '#22c55e' : file.status === 'error' ? '#ef4444' : 'var(--color-primary)' }"></div>
561
+ </div>
562
+ <div class="progress-text">${ Math.round( file.progress ) }%</div>
563
+ </div>
564
+ ${ file.error ? `<div class="status error">${ file.error }</div>` : '' }
565
+ </div>
566
+ ` ).join( '' );
567
+
568
+ // Bind remove button events
569
+ const removeButtons = container.querySelectorAll( '.remove-btn' );
570
+ removeButtons.forEach( btn => {
571
+ btn.addEventListener( 'click', ( e ) => {
572
+ e.stopPropagation();
573
+ const fileId = ( btn as HTMLElement ).dataset.fileId;
574
+ if ( fileId ) this.removeFile( fileId );
575
+ } );
576
+ } );
577
+
578
+ // Show/hide upload button
579
+ const uploadBtn = this.shadowRoot.querySelector( '#uploadBtn' ) as HTMLButtonElement;
580
+ if ( uploadBtn ) {
581
+ const hasPendingFiles = Array.from( this.files.values() ).some( f => f.status === 'pending' );
582
+ uploadBtn.style.display = hasPendingFiles ? 'block' : 'none';
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Binds event listeners
588
+ */
589
+ private bindEvents (): void {
590
+ const uploadZone = this.shadowRoot.querySelector( '#uploadZone' ) as HTMLElement;
591
+ const fileInput = this.shadowRoot.querySelector( '#fileInput' ) as HTMLInputElement;
592
+ const browseBtn = this.shadowRoot.querySelector( '#browseBtn' ) as HTMLButtonElement;
593
+ const uploadBtn = this.shadowRoot.querySelector( '#uploadBtn' ) as HTMLButtonElement;
594
+ const abortBtn = this.shadowRoot.querySelector( '#abortBtn' ) as HTMLButtonElement;
595
+
596
+ // Click to browse
597
+ browseBtn?.addEventListener( 'click', () => fileInput?.click() );
598
+
599
+ // Drag and drop
600
+ uploadZone?.addEventListener( 'dragover', ( e ) => {
601
+ e.preventDefault();
602
+ uploadZone.classList.add( 'drag-over' );
603
+ } );
604
+
605
+ uploadZone?.addEventListener( 'dragleave', () => {
606
+ uploadZone.classList.remove( 'drag-over' );
607
+ } );
608
+
609
+ uploadZone?.addEventListener( 'drop', ( e ) => {
610
+ e.preventDefault();
611
+ uploadZone.classList.remove( 'drag-over' );
612
+ if ( e.dataTransfer?.files ) {
613
+ this.addFiles( e.dataTransfer.files );
614
+ }
615
+ } );
616
+
617
+ // File input change
618
+ fileInput?.addEventListener( 'change', ( e ) => {
619
+ const files = ( e.target as HTMLInputElement ).files;
620
+ if ( files ) {
621
+ this.addFiles( files );
622
+ // Reset input so same file can be added again
623
+ fileInput.value = '';
624
+ }
625
+ } );
626
+
627
+ // Upload button
628
+ uploadBtn?.addEventListener( 'click', () => this.startUpload() );
629
+
630
+ // Abort button
631
+ abortBtn?.addEventListener( 'click', () => this.abortAllUploads() );
632
+ }
633
+
634
+ /**
635
+ * Renders the component
636
+ */
637
+ private render (): void {
638
+ this.shadowRoot.innerHTML = `
639
+ <style>
640
+ :host {
641
+ display: block;
642
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
643
+ }
644
+
645
+ * {
646
+ box-sizing: border-box;
647
+ }
648
+
649
+ .container {
650
+ width: 100%;
651
+ }
652
+
653
+ .upload-zone {
654
+ border: 2px dashed #ccc;
655
+ border-radius: 8px;
656
+ padding: 40px;
657
+ text-align: center;
658
+ cursor: pointer;
659
+ transition: all 0.3s ease;
660
+ background: #fafafa;
661
+ }
662
+
663
+ .upload-zone:hover {
664
+ border-color: #4CAF50;
665
+ background: #f0f9f0;
666
+ }
667
+
668
+ .upload-zone.drag-over {
669
+ border-color: #4CAF50;
670
+ background: #e8f5e9;
671
+ }
672
+
673
+ .upload-zone-content {
674
+ pointer-events: none;
675
+ }
676
+
677
+ .upload-icon {
678
+ font-size: 48px;
679
+ margin-bottom: 16px;
680
+ }
681
+
682
+ .upload-text {
683
+ font-size: 18px;
684
+ margin-bottom: 8px;
685
+ color: #333;
686
+ }
687
+
688
+ .upload-hint {
689
+ font-size: 14px;
690
+ color: #666;
691
+ }
692
+
693
+ .browse-btn {
694
+ background: #4CAF50;
695
+ color: white;
696
+ border: none;
697
+ padding: 12px 24px;
698
+ border-radius: 6px;
699
+ font-size: 16px;
700
+ font-weight: 500;
701
+ cursor: pointer;
702
+ margin-top: 16px;
703
+ transition: background 0.3s ease;
704
+ pointer-events: auto;
705
+ }
706
+
707
+ .browse-btn:hover {
708
+ background: #45a049;
709
+ }
710
+
711
+ input[type="file"] {
712
+ display: none;
713
+ }
714
+
715
+ .file-cards-container {
716
+ display: flex;
717
+ flex-wrap: wrap;
718
+ gap: 16px;
719
+ margin-top: 24px;
720
+ }
721
+
722
+ .file-card {
723
+ position: relative;
724
+ width: 200px;
725
+ border: 1px solid #e0e0e0;
726
+ border-radius: 8px;
727
+ padding: 12px;
728
+ background: white;
729
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
730
+ transition: box-shadow 0.3s ease;
731
+ }
732
+
733
+ .file-card:hover {
734
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
735
+ }
736
+
737
+ .remove-btn {
738
+ position: absolute;
739
+ top: 8px;
740
+ right: 8px;
741
+ width: 24px;
742
+ height: 24px;
743
+ border: none;
744
+ border-radius: 50%;
745
+ background: rgba(239, 68, 68, 0.9);
746
+ color: white;
747
+ font-size: 18px;
748
+ line-height: 1;
749
+ cursor: pointer;
750
+ display: flex;
751
+ align-items: center;
752
+ justify-content: center;
753
+ padding: 0;
754
+ transition: background 0.3s ease;
755
+ z-index: 1;
756
+ }
757
+
758
+ .remove-btn:hover {
759
+ background: rgba(239, 68, 68, 1);
760
+ }
761
+
762
+ .preview {
763
+ width: 100%;
764
+ height: 120px;
765
+ border-radius: 4px;
766
+ overflow: hidden;
767
+ background: #f5f5f5;
768
+ display: flex;
769
+ align-items: center;
770
+ justify-content: center;
771
+ margin-bottom: 8px;
772
+ }
773
+
774
+ .preview img {
775
+ width: 100%;
776
+ height: 100%;
777
+ object-fit: cover;
778
+ }
779
+
780
+ .preview.no-preview {
781
+ font-size: 48px;
782
+ }
783
+
784
+ .file-info {
785
+ margin-bottom: 8px;
786
+ }
787
+
788
+ .file-name {
789
+ font-size: 14px;
790
+ font-weight: 500;
791
+ color: #333;
792
+ white-space: nowrap;
793
+ overflow: hidden;
794
+ text-overflow: ellipsis;
795
+ margin-bottom: 4px;
796
+ }
797
+
798
+ .file-size {
799
+ font-size: 12px;
800
+ color: #666;
801
+ }
802
+
803
+ .progress-container {
804
+ margin-top: 8px;
805
+ }
806
+
807
+ .progress-bar-bg {
808
+ width: 100%;
809
+ height: 8px;
810
+ background: #e0e0e0;
811
+ border-radius: 4px;
812
+ overflow: hidden;
813
+ margin-bottom: 4px;
814
+ }
815
+
816
+ .progress-bar {
817
+ height: 100%;
818
+ background: var(--color-primary, #4CAF50);
819
+ transition: width 0.3s ease, background-color 0.3s ease;
820
+ }
821
+
822
+ .progress-text {
823
+ font-size: 12px;
824
+ color: #666;
825
+ text-align: center;
826
+ }
827
+
828
+ .status {
829
+ font-size: 12px;
830
+ color: #ef4444;
831
+ margin-top: 4px;
832
+ display: none;
833
+ }
834
+
835
+ .status.error {
836
+ display: block;
837
+ }
838
+
839
+ .upload-btn {
840
+ background: #4CAF50;
841
+ color: white;
842
+ border: none;
843
+ padding: 12px 32px;
844
+ border-radius: 6px;
845
+ font-size: 16px;
846
+ font-weight: 500;
847
+ cursor: pointer;
848
+ margin-top: 24px;
849
+ transition: background 0.3s ease;
850
+ display: none;
851
+ }
852
+
853
+ .upload-btn:hover:not(:disabled) {
854
+ background: #45a049;
855
+ }
856
+
857
+ .upload-btn:disabled {
858
+ opacity: 0.6;
859
+ cursor: not-allowed;
860
+ }
861
+
862
+ .abort-btn {
863
+ background: #ef4444;
864
+ color: white;
865
+ border: none;
866
+ padding: 12px 32px;
867
+ border-radius: 6px;
868
+ font-size: 16px;
869
+ font-weight: 500;
870
+ cursor: pointer;
871
+ margin-top: 24px;
872
+ margin-left: 12px;
873
+ transition: background 0.3s ease;
874
+ display: none;
875
+ }
876
+
877
+ .abort-btn:hover {
878
+ background: #dc2626;
879
+ }
880
+
881
+ .buttons-container {
882
+ display: flex;
883
+ align-items: center;
884
+ }
885
+ </style>
886
+
887
+ <div class="container">
888
+ <div class="upload-zone" id="uploadZone">
889
+ <input type="file" id="fileInput" multiple>
890
+ <div class="upload-zone-content">
891
+ <div class="upload-icon">📁</div>
892
+ <div class="upload-text">Drop files here</div>
893
+ <div class="upload-hint">or</div>
894
+ <button class="browse-btn" id="browseBtn">Browse Files</button>
895
+ </div>
896
+ </div>
897
+
898
+ <div class="file-cards-container" id="fileCardsContainer"></div>
899
+
900
+ <div class="buttons-container">
901
+ <button class="upload-btn" id="uploadBtn">Upload Files</button>
902
+ <button class="abort-btn" id="abortBtn">Abort Upload</button>
903
+ </div>
904
+ </div>
905
+ `;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Defines the custom element if not already defined
911
+ */
912
+ export const defineChunkUploader = ( tagName = 'liwe3-chunk-uploader' ): void => {
913
+ if ( typeof window !== 'undefined' && ! customElements.get( tagName ) ) {
914
+ customElements.define( tagName, ChunkUploaderElement );
915
+ }
916
+ };
917
+
918
+ // Auto-register with default tag name
919
+ if ( typeof window !== 'undefined' ) {
920
+ defineChunkUploader();
921
+ }