@liwe3/webcomponents 1.0.14 → 1.1.10

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