@liwe3/webcomponents 1.1.0 → 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 (56) 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 +10 -0
  6. package/dist/AITextEditor.d.ts.map +1 -1
  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 +22 -0
  18. package/dist/ChunkUploader.d.ts.map +1 -1
  19. package/dist/ChunkUploader.js +245 -103
  20. package/dist/ChunkUploader.js.map +1 -1
  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/Dialog.d.ts +102 -0
  26. package/dist/Dialog.d.ts.map +1 -0
  27. package/dist/Dialog.js +299 -0
  28. package/dist/Dialog.js.map +1 -0
  29. package/dist/MarkdownPreview.d.ts +25 -0
  30. package/dist/MarkdownPreview.d.ts.map +1 -0
  31. package/dist/MarkdownPreview.js +147 -0
  32. package/dist/MarkdownPreview.js.map +1 -0
  33. package/dist/ResizableCropper.d.ts +158 -0
  34. package/dist/ResizableCropper.d.ts.map +1 -0
  35. package/dist/ResizableCropper.js +562 -0
  36. package/dist/ResizableCropper.js.map +1 -0
  37. package/dist/SmartSelect.d.ts +1 -0
  38. package/dist/SmartSelect.d.ts.map +1 -1
  39. package/dist/SmartSelect.js +45 -2
  40. package/dist/SmartSelect.js.map +1 -1
  41. package/dist/index.d.ts +16 -9
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +52 -29
  44. package/dist/index.js.map +1 -1
  45. package/package.json +33 -3
  46. package/src/AIMarkdownEditor.ts +568 -0
  47. package/src/AITextEditor.ts +97 -2
  48. package/src/ButtonToolbar.ts +302 -0
  49. package/src/CheckList.ts +438 -0
  50. package/src/ChunkUploader.ts +837 -623
  51. package/src/ComicBalloon.ts +709 -0
  52. package/src/Dialog.ts +510 -0
  53. package/src/MarkdownPreview.ts +213 -0
  54. package/src/ResizableCropper.ts +1099 -0
  55. package/src/SmartSelect.ts +48 -2
  56. package/src/index.ts +110 -47
@@ -4,638 +4,786 @@
4
4
  */
5
5
 
6
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
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
16
  }
17
17
 
18
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;
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
26
31
  }
27
32
 
28
33
  const DEFAULT_CHUNK_SIZE = 5; // 5MB - R2/S3 minimum part size (except last part)
29
34
  const DEFAULT_MAX_FILE_SIZE = 5120; // 5GB in MB
30
35
 
31
36
  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>' }
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>'}
554
700
  <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>
701
+ <div class="file-name" title="${file.file.name}">${file.file.name}</div>
702
+ <div class="file-size">${this.formatBytes( file.file.size )}</div>
557
703
  </div>
558
704
  <div class="progress-container">
559
705
  <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>
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>
561
709
  </div>
562
- <div class="progress-text">${ Math.round( file.progress ) }%</div>
710
+ <div class="progress-text">${Math.round( file.progress )}%</div>
563
711
  </div>
564
- ${ file.error ? `<div class="status error">${ file.error }</div>` : '' }
712
+ ${file.error ? `<div class="status error">${file.error}</div>` : ''}
565
713
  </div>
566
714
  ` ).join( '' );
567
715
 
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 = `
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 = `
639
787
  <style>
640
788
  :host {
641
789
  display: block;
@@ -653,7 +801,7 @@ export class ChunkUploaderElement extends HTMLElement {
653
801
  .upload-zone {
654
802
  border: 2px dashed #ccc;
655
803
  border-radius: 8px;
656
- padding: 40px;
804
+ padding: 10px;
657
805
  text-align: center;
658
806
  cursor: pointer;
659
807
  transition: all 0.3s ease;
@@ -882,6 +1030,72 @@ export class ChunkUploaderElement extends HTMLElement {
882
1030
  display: flex;
883
1031
  align-items: center;
884
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
+ }
885
1099
  </style>
886
1100
 
887
1101
  <div class="container">
@@ -889,9 +1103,9 @@ export class ChunkUploaderElement extends HTMLElement {
889
1103
  <input type="file" id="fileInput" multiple>
890
1104
  <div class="upload-zone-content">
891
1105
  <div class="upload-icon">📁</div>
892
- <div class="upload-text">Drop files here</div>
1106
+ <div class="upload-text">${this.config.labelDropFiles || 'Drop files here'}</div>
893
1107
  <div class="upload-hint">or</div>
894
- <button class="browse-btn" id="browseBtn">Browse Files</button>
1108
+ <button class="browse-btn" id="browseBtn">${this.config.labelBrowse || 'Browse Files'}</button>
895
1109
  </div>
896
1110
  </div>
897
1111
 
@@ -903,19 +1117,19 @@ export class ChunkUploaderElement extends HTMLElement {
903
1117
  </div>
904
1118
  </div>
905
1119
  `;
906
- }
1120
+ }
907
1121
  }
908
1122
 
909
1123
  /**
910
1124
  * Defines the custom element if not already defined
911
1125
  */
912
- export const defineChunkUploader = ( tagName = 'liwe3-chunk-uploader' ): void => {
913
- if ( typeof window !== 'undefined' && ! customElements.get( tagName ) ) {
914
- customElements.define( tagName, ChunkUploaderElement );
915
- }
1126
+ export const defineChunkUploader = ( tagName = 'liwe3-chunk-uploader' ) : void => {
1127
+ if ( typeof window !== 'undefined' && !customElements.get( tagName ) ) {
1128
+ customElements.define( tagName, ChunkUploaderElement );
1129
+ }
916
1130
  };
917
1131
 
918
1132
  // Auto-register with default tag name
919
1133
  if ( typeof window !== 'undefined' ) {
920
- defineChunkUploader();
1134
+ defineChunkUploader();
921
1135
  }