@doyosi/laraisy 1.0.1 → 1.0.3

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 (51) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/src/CodeInput.js +48 -48
  4. package/src/DSAlert.js +352 -352
  5. package/src/DSAvatar.js +207 -207
  6. package/src/DSDelete.js +274 -274
  7. package/src/DSForm.js +568 -568
  8. package/src/DSGridOrTable.js +453 -453
  9. package/src/DSLocaleSwitcher.js +239 -239
  10. package/src/DSLogout.js +293 -293
  11. package/src/DSNotifications.js +365 -365
  12. package/src/DSRestore.js +181 -181
  13. package/src/DSSelect.js +1071 -1071
  14. package/src/DSSelectBox.js +563 -563
  15. package/src/DSSimpleSlider.js +517 -517
  16. package/src/DSSvgFetch.js +69 -69
  17. package/src/DSTable/DSTableExport.js +68 -68
  18. package/src/DSTable/DSTableFilter.js +224 -224
  19. package/src/DSTable/DSTablePagination.js +136 -136
  20. package/src/DSTable/DSTableSearch.js +40 -40
  21. package/src/DSTable/DSTableSelection.js +192 -192
  22. package/src/DSTable/DSTableSort.js +58 -58
  23. package/src/DSTable.js +353 -353
  24. package/src/DSTabs.js +488 -488
  25. package/src/DSUpload.js +887 -887
  26. package/dist/CodeInput.d.ts +0 -10
  27. package/dist/DSAlert.d.ts +0 -112
  28. package/dist/DSAvatar.d.ts +0 -45
  29. package/dist/DSDelete.d.ts +0 -61
  30. package/dist/DSForm.d.ts +0 -151
  31. package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
  32. package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
  33. package/dist/DSGridOrTable.d.ts +0 -296
  34. package/dist/DSLocaleSwitcher.d.ts +0 -71
  35. package/dist/DSLogout.d.ts +0 -76
  36. package/dist/DSNotifications.d.ts +0 -54
  37. package/dist/DSRestore.d.ts +0 -56
  38. package/dist/DSSelect.d.ts +0 -221
  39. package/dist/DSSelectBox.d.ts +0 -123
  40. package/dist/DSSimpleSlider.d.ts +0 -136
  41. package/dist/DSSvgFetch.d.ts +0 -17
  42. package/dist/DSTable/DSTableExport.d.ts +0 -11
  43. package/dist/DSTable/DSTableFilter.d.ts +0 -40
  44. package/dist/DSTable/DSTablePagination.d.ts +0 -12
  45. package/dist/DSTable/DSTableSearch.d.ts +0 -8
  46. package/dist/DSTable/DSTableSelection.d.ts +0 -46
  47. package/dist/DSTable/DSTableSort.d.ts +0 -8
  48. package/dist/DSTable.d.ts +0 -116
  49. package/dist/DSTabs.d.ts +0 -156
  50. package/dist/DSUpload.d.ts +0 -220
  51. package/dist/index.d.ts +0 -17
package/src/DSUpload.js CHANGED
@@ -1,887 +1,887 @@
1
- /**
2
- * DSUpload
3
- *
4
- * A comprehensive file upload component with:
5
- * - Image/file preview
6
- * - Progress bar support
7
- * - File type validation
8
- * - File size validation
9
- * - Old value / default image support
10
- * - Reset functionality
11
- * - Drag and drop support
12
- * - Full event system
13
- */
14
- export class DSUpload {
15
- static instances = new Map();
16
- static instanceCounter = 0;
17
-
18
- /**
19
- * Default Icons
20
- */
21
- static icons = {
22
- upload: `<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>`,
23
- file: `<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>`,
24
- image: `<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>`,
25
- close: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`,
26
- reset: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>`,
27
- check: `<svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>`
28
- };
29
-
30
- /**
31
- * Default configuration
32
- */
33
- static defaults = {
34
- // Preview options
35
- preview: true, // Enable preview
36
- previewMaxHeight: '200px', // Max height for preview image
37
- defaultImage: null, // Default/placeholder image URL
38
- oldValue: null, // Old/current file URL (for edit forms)
39
-
40
- // File validation
41
- accept: '*', // Accepted file types (e.g., 'image/*', '.pdf,.doc')
42
- maxSize: 5 * 1024 * 1024, // Max file size in bytes (default 5MB)
43
- minSize: 0, // Min file size in bytes
44
-
45
- // UI options
46
- showProgressBar: true, // Show upload progress bar
47
- showFileInfo: true, // Show file name and size
48
- showResetButton: true, // Show reset button when file selected
49
- showRemoveButton: true, // Show remove/clear button
50
- dropzone: true, // Enable drag and drop
51
- dropzoneText: 'Drop file here or click to upload',
52
- browseText: 'Browse',
53
-
54
- // Styling
55
- wrapperClass: '',
56
- previewClass: '',
57
- dropzoneClass: '',
58
-
59
- // Behavior
60
- autoUpload: false, // Auto upload on select (requires uploadUrl)
61
- uploadUrl: null, // URL for AJAX upload
62
- removeUrl: null, // URL for AJAX removal (DELETE request)
63
- uploadMethod: 'POST', // Upload HTTP method
64
- uploadFieldName: null, // Override form field name for upload
65
-
66
- // Size display
67
- sizeUnit: 'auto', // 'auto', 'KB', 'MB', 'GB'
68
-
69
- // Translations
70
- translations: {
71
- dropzone: 'Drop file here or click to upload',
72
- browse: 'Browse',
73
- remove: 'Remove',
74
- reset: 'Reset',
75
- fileTooBig: 'File is too large. Maximum size is {max}',
76
- fileTooSmall: 'File is too small. Minimum size is {min}',
77
- invalidType: 'Invalid file type. Accepted types: {types}',
78
- uploadError: 'Upload failed',
79
- uploading: 'Uploading...'
80
- }
81
- };
82
-
83
- /**
84
- * @param {string|HTMLElement} selector - Input element or selector
85
- * @param {Object} config - Configuration options
86
- */
87
- constructor(selector, config = {}) {
88
- this.instanceId = `ds-upload-${++DSUpload.instanceCounter}`;
89
-
90
- // Find the input element
91
- const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
92
-
93
- if (!el) {
94
- throw new Error('DSUpload: Element not found.');
95
- }
96
-
97
- // Store original input
98
- this.originalInput = el.tagName === 'INPUT' ? el : el.querySelector('input[type="file"]');
99
- if (!this.originalInput) {
100
- throw new Error('DSUpload: No file input found.');
101
- }
102
-
103
- // Find or create wrapper
104
- this.wrapper = el.closest('.form-group') || el.parentElement;
105
-
106
- // Merge config with data attributes
107
- this.cfg = this._buildConfig(config);
108
-
109
- // State
110
- this._file = null;
111
- this._previewUrl = null;
112
- this._isUploading = false;
113
- this._progress = 0;
114
- this._originalValue = this.cfg.oldValue || this.cfg.defaultImage;
115
-
116
- // Event listeners
117
- this._listeners = {};
118
- this._boundHandlers = {};
119
-
120
- // Initialize
121
- this._init();
122
-
123
- // Register instance
124
- DSUpload.instances.set(this.instanceId, this);
125
- this.wrapper.dataset.dsUploadId = this.instanceId;
126
- }
127
-
128
- /**
129
- * Static factory
130
- */
131
- static create(selector, config = {}) {
132
- return new DSUpload(selector, config);
133
- }
134
-
135
- /**
136
- * Get instance by element
137
- */
138
- static getInstance(element) {
139
- const el = typeof element === 'string' ? document.querySelector(element) : element;
140
- if (!el) return null;
141
- const wrapper = el.closest('[data-ds-upload-id]');
142
- if (!wrapper) return null;
143
- return DSUpload.instances.get(wrapper.dataset.dsUploadId);
144
- }
145
-
146
- /**
147
- * Auto-initialize all elements with [data-ds-upload]
148
- */
149
- static initAll(selector = '[data-ds-upload]') {
150
- document.querySelectorAll(selector).forEach(el => {
151
- if (!el.closest('[data-ds-upload-id]')) {
152
- new DSUpload(el);
153
- }
154
- });
155
- }
156
-
157
- // ==================== INITIALIZATION ====================
158
-
159
- _buildConfig(userConfig) {
160
- const dataConfig = this._parseDataAttributes();
161
- return { ...DSUpload.defaults, ...dataConfig, ...userConfig };
162
- }
163
-
164
- _parseDataAttributes() {
165
- const data = this.originalInput.dataset;
166
- const config = {};
167
-
168
- if (data.preview !== undefined) config.preview = data.preview !== 'false';
169
- if (data.defaultImage) config.defaultImage = data.defaultImage;
170
- if (data.fallbackImage) config.fallbackImage = data.fallbackImage;
171
- if (data.oldValue) config.oldValue = data.oldValue;
172
- if (data.accept) config.accept = data.accept;
173
- if (data.maxSize) config.maxSize = parseInt(data.maxSize, 10);
174
- if (data.minSize) config.minSize = parseInt(data.minSize, 10);
175
- if (data.dropzone !== undefined) config.dropzone = data.dropzone !== 'false';
176
- if (data.showProgressBar !== undefined) config.showProgressBar = data.showProgressBar !== 'false';
177
- if (data.uploadUrl) config.uploadUrl = data.uploadUrl;
178
- if (data.removeUrl) config.removeUrl = data.removeUrl;
179
-
180
- return config;
181
- }
182
-
183
- _init() {
184
- this._buildDOM();
185
- this._cacheElements();
186
- this._bindEvents();
187
- this._setInitialPreview();
188
-
189
- if (this.cfg.accept && this.cfg.accept !== '*') {
190
- this.originalInput.setAttribute('accept', this.cfg.accept);
191
- }
192
- }
193
-
194
- _buildDOM() {
195
- // Hide original input
196
- this.originalInput.style.display = 'none';
197
- this.originalInput.classList.add('ds-upload-input');
198
-
199
- // Create upload UI
200
- const container = document.createElement('div');
201
- container.className = `ds-upload-container relative ${this.cfg.wrapperClass}`;
202
- container.innerHTML = `
203
- <!-- Dropzone / Upload Area -->
204
- <div class="ds-upload-dropzone border-2 border-dashed border-base-300 rounded-sm p-3 pb-1 text-center cursor-pointer transition-all hover:border-primary hover:bg-base-200/50 ${this.cfg.dropzoneClass}"
205
- data-ds-upload-dropzone>
206
- <div class="ds-upload-placeholder flex flex-col items-center gap-2" data-ds-upload-placeholder>
207
- <span class="text-base-content/40">${DSUpload.icons.upload}</span>
208
- <p class="text-sm text-base-content/60">${this.cfg.translations.dropzone}</p>
209
- <button type="button" class="btn btn-primary btn-sm mt-2" data-ds-upload-browse>
210
- ${this.cfg.translations.browse}
211
- </button>
212
- </div>
213
-
214
- <!-- Preview Area -->
215
- <div class="ds-upload-preview hidden" data-ds-upload-preview>
216
- <div class="relative inline-block">
217
- <img src="" alt="Preview"
218
- class="max-h-[${this.cfg.previewMaxHeight}] max-w-full rounded-lg object-contain mx-auto ${this.cfg.previewClass}"
219
- data-ds-upload-preview-img>
220
- <div class="ds-upload-file-preview hidden flex-col items-center gap-2" data-ds-upload-file-preview>
221
- <span class="text-base-content/40">${DSUpload.icons.file}</span>
222
- <span class="text-sm font-medium" data-ds-upload-filename></span>
223
- </div>
224
- </div>
225
-
226
- <!-- File Info -->
227
- <div class="ds-upload-info mt-3 text-center" data-ds-upload-info>
228
- <p class="text-sm font-medium text-base-content" data-ds-upload-name></p>
229
- <p class="text-xs text-base-content/60" data-ds-upload-size></p>
230
- </div>
231
-
232
- <!-- Action Buttons -->
233
- <div class="ds-upload-actions flex justify-center gap-2 mt-3">
234
- <button type="button" class="btn btn-ghost btn-sm gap-1" data-ds-upload-reset title="${this.cfg.translations.reset}">
235
- ${DSUpload.icons.reset}
236
- <span>${this.cfg.translations.reset}</span>
237
- </button>
238
- <button type="button" class="btn btn-ghost btn-sm text-error gap-1" data-ds-upload-remove title="${this.cfg.translations.remove}">
239
- ${DSUpload.icons.close}
240
- <span>${this.cfg.translations.remove}</span>
241
- </button>
242
- </div>
243
- </div>
244
-
245
- <!-- Progress Bar -->
246
- <div class="ds-upload-progress hidden mt-4" data-ds-upload-progress>
247
- <div class="flex justify-between text-xs text-base-content/60 mb-1">
248
- <span>${this.cfg.translations.uploading}</span>
249
- <span data-ds-upload-progress-text>0%</span>
250
- </div>
251
- <progress class="progress progress-primary w-full" value="0" max="100" data-ds-upload-progress-bar></progress>
252
- </div>
253
- </div>
254
-
255
- <!-- Error Message -->
256
- <div class="ds-upload-error hidden mt-2" data-ds-upload-error>
257
- <p class="text-sm text-error flex items-center gap-1">
258
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
259
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
260
- </svg>
261
- <span data-ds-upload-error-text></span>
262
- </p>
263
- </div>
264
- `;
265
-
266
- // Insert after original input
267
- this.originalInput.insertAdjacentElement('afterend', container);
268
- this.container = container;
269
- }
270
-
271
- _cacheElements() {
272
- this.elements = {
273
- dropzone: this.container.querySelector('[data-ds-upload-dropzone]'),
274
- placeholder: this.container.querySelector('[data-ds-upload-placeholder]'),
275
- preview: this.container.querySelector('[data-ds-upload-preview]'),
276
- previewImg: this.container.querySelector('[data-ds-upload-preview-img]'),
277
- filePreview: this.container.querySelector('[data-ds-upload-file-preview]'),
278
- filename: this.container.querySelector('[data-ds-upload-filename]'),
279
- info: this.container.querySelector('[data-ds-upload-info]'),
280
- name: this.container.querySelector('[data-ds-upload-name]'),
281
- size: this.container.querySelector('[data-ds-upload-size]'),
282
- reset: this.container.querySelector('[data-ds-upload-reset]'),
283
- remove: this.container.querySelector('[data-ds-upload-remove]'),
284
- browse: this.container.querySelector('[data-ds-upload-browse]'),
285
- progress: this.container.querySelector('[data-ds-upload-progress]'),
286
- progressBar: this.container.querySelector('[data-ds-upload-progress-bar]'),
287
- progressText: this.container.querySelector('[data-ds-upload-progress-text]'),
288
- error: this.container.querySelector('[data-ds-upload-error]'),
289
- errorText: this.container.querySelector('[data-ds-upload-error-text]')
290
- };
291
- }
292
-
293
- _bindEvents() {
294
- this._boundHandlers = {
295
- onInputChange: this._onInputChange.bind(this),
296
- onDropzoneClick: this._onDropzoneClick.bind(this),
297
- onDragOver: this._onDragOver.bind(this),
298
- onDragLeave: this._onDragLeave.bind(this),
299
- onDrop: this._onDrop.bind(this),
300
- onReset: this._onReset.bind(this),
301
- onRemove: this._onRemove.bind(this),
302
- onBrowse: this._onBrowse.bind(this)
303
- };
304
-
305
- // Input change
306
- this.originalInput.addEventListener('change', this._boundHandlers.onInputChange);
307
-
308
- // Dropzone click
309
- this.elements.dropzone.addEventListener('click', this._boundHandlers.onDropzoneClick);
310
-
311
- // Drag and drop
312
- if (this.cfg.dropzone) {
313
- this.elements.dropzone.addEventListener('dragover', this._boundHandlers.onDragOver);
314
- this.elements.dropzone.addEventListener('dragleave', this._boundHandlers.onDragLeave);
315
- this.elements.dropzone.addEventListener('drop', this._boundHandlers.onDrop);
316
- }
317
-
318
- // Buttons
319
- this.elements.browse.addEventListener('click', this._boundHandlers.onBrowse);
320
- this.elements.reset.addEventListener('click', this._boundHandlers.onReset);
321
- this.elements.remove.addEventListener('click', this._boundHandlers.onRemove);
322
- }
323
-
324
- _setInitialPreview() {
325
- const initialUrl = this.cfg.oldValue || this.cfg.defaultImage;
326
- // Treat removeUrl as proof of a real value existing on the server
327
- const hasRealValue = !!this.cfg.oldValue || !!this.cfg.removeUrl;
328
-
329
- if (initialUrl && this.cfg.preview) {
330
- this._showPreview(initialUrl, null, true, hasRealValue);
331
- }
332
-
333
- // Hide reset button initially (no new file selected yet)
334
- this.elements.reset.classList.add('hidden');
335
-
336
- // Hide remove button if only showing default placeholder logic handled in _showPreview
337
- // based on hasRealValue passed above.
338
-
339
- this._emit('ready', { hasInitialValue: !!initialUrl, hasRealValue });
340
- }
341
-
342
- // ==================== EVENT HANDLERS ====================
343
-
344
- _onInputChange(e) {
345
- const file = e.target.files[0];
346
- if (file) {
347
- this._processFile(file);
348
- }
349
- }
350
-
351
- _onDropzoneClick(e) {
352
- // Don't trigger if clicking buttons
353
- if (e.target.closest('button')) return;
354
- this.originalInput.click();
355
- }
356
-
357
- _onBrowse(e) {
358
- e.stopPropagation();
359
- this.originalInput.click();
360
- }
361
-
362
- _onDragOver(e) {
363
- e.preventDefault();
364
- e.stopPropagation();
365
- this.elements.dropzone.classList.add('border-primary', 'bg-primary/10');
366
- }
367
-
368
- _onDragLeave(e) {
369
- e.preventDefault();
370
- e.stopPropagation();
371
- this.elements.dropzone.classList.remove('border-primary', 'bg-primary/10');
372
- }
373
-
374
- _onDrop(e) {
375
- e.preventDefault();
376
- e.stopPropagation();
377
- this.elements.dropzone.classList.remove('border-primary', 'bg-primary/10');
378
-
379
- const file = e.dataTransfer.files[0];
380
- if (file) {
381
- this._processFile(file);
382
- }
383
- }
384
-
385
- _onReset(e) {
386
- e.stopPropagation();
387
- this.reset();
388
- }
389
-
390
- async _onRemove(e) {
391
- e.stopPropagation();
392
-
393
- // If we have a removeUrl and a real value (implied by removeUrl presence), try to delete via AJAX
394
- // validation: ensure we don't have a new file selected (this._file should be null)
395
- if (this.cfg.removeUrl && !this._file) {
396
- if (confirm('Are you sure you want to remove this file?')) {
397
- const success = await this.remove();
398
- if (success) {
399
- // File is deleted from server.
400
- // If we have a fallback image, set it as the new default
401
- // Otherwise clear default so we show placeholder
402
- this.cfg.defaultImage = this.cfg.fallbackImage || null;
403
-
404
- this.cfg.oldValue = null;
405
- // Also clear removeUrl to prevent trying to delete again
406
- this.cfg.removeUrl = null;
407
-
408
- this.clear();
409
- }
410
- }
411
- } else {
412
- this.clear();
413
- }
414
- }
415
-
416
- // ==================== FILE PROCESSING ====================
417
-
418
- _processFile(file) {
419
- // Clear previous errors
420
- this._hideError();
421
-
422
- // Validate file type
423
- if (!this._validateType(file)) {
424
- this._showError(
425
- this.cfg.translations.invalidType.replace('{types}', this.cfg.accept)
426
- );
427
- this._emit('error', { error: 'invalid_type', file });
428
- return false;
429
- }
430
-
431
- // Validate file size
432
- if (!this._validateSize(file)) {
433
- if (file.size > this.cfg.maxSize) {
434
- this._showError(
435
- this.cfg.translations.fileTooBig.replace('{max}', this._formatSize(this.cfg.maxSize))
436
- );
437
- } else {
438
- this._showError(
439
- this.cfg.translations.fileTooSmall.replace('{min}', this._formatSize(this.cfg.minSize))
440
- );
441
- }
442
- this._emit('error', { error: 'invalid_size', file });
443
- return false;
444
- }
445
-
446
- // Store file
447
- this._file = file;
448
-
449
- // Update input
450
- const dt = new DataTransfer();
451
- dt.items.add(file);
452
- this.originalInput.files = dt.files;
453
-
454
- // Show preview
455
- if (this.cfg.preview) {
456
- if (this._isImage(file)) {
457
- const reader = new FileReader();
458
- reader.onload = (e) => {
459
- this._showPreview(e.target.result, file);
460
- };
461
- reader.readAsDataURL(file);
462
- } else {
463
- this._showPreview(null, file);
464
- }
465
- }
466
-
467
- // Auto upload
468
- if (this.cfg.autoUpload && this.cfg.uploadUrl) {
469
- this.upload();
470
- }
471
-
472
- this._emit('select', { file });
473
- return true;
474
- }
475
-
476
- _validateType(file) {
477
- if (!this.cfg.accept || this.cfg.accept === '*') return true;
478
-
479
- const accept = this.cfg.accept.toLowerCase().split(',').map(t => t.trim());
480
- const fileType = file.type.toLowerCase();
481
- const fileExt = '.' + file.name.split('.').pop().toLowerCase();
482
-
483
- return accept.some(type => {
484
- if (type.endsWith('/*')) {
485
- // Wildcard like image/*
486
- return fileType.startsWith(type.replace('/*', '/'));
487
- }
488
- if (type.startsWith('.')) {
489
- // Extension like .pdf
490
- return fileExt === type;
491
- }
492
- // Exact MIME type
493
- return fileType === type;
494
- });
495
- }
496
-
497
- _validateSize(file) {
498
- if (this.cfg.maxSize && file.size > this.cfg.maxSize) return false;
499
- if (this.cfg.minSize && file.size < this.cfg.minSize) return false;
500
- return true;
501
- }
502
-
503
- _isImage(file) {
504
- return file.type.startsWith('image/');
505
- }
506
-
507
- // ==================== UI UPDATES ====================
508
-
509
- _showPreview(url, file, isInitial = false, hasRealValue = false) {
510
- this.elements.placeholder.classList.add('hidden');
511
- this.elements.preview.classList.remove('hidden');
512
-
513
- if (url && (isInitial || (file && this._isImage(file)))) {
514
- // Image preview
515
- this.elements.previewImg.src = url;
516
- this.elements.previewImg.classList.remove('hidden');
517
- this.elements.filePreview.classList.add('hidden');
518
- this._previewUrl = url;
519
- } else {
520
- // File icon preview
521
- this.elements.previewImg.classList.add('hidden');
522
- this.elements.filePreview.classList.remove('hidden');
523
- this.elements.filePreview.classList.add('flex');
524
- if (file) {
525
- this.elements.filename.textContent = file.name;
526
- }
527
- }
528
-
529
- // File info
530
- if (file && this.cfg.showFileInfo) {
531
- this.elements.info.classList.remove('hidden');
532
- this.elements.name.textContent = file.name;
533
- this.elements.size.textContent = this._formatSize(file.size);
534
- } else if (isInitial) {
535
- this.elements.info.classList.add('hidden');
536
- }
537
-
538
- // Show/hide buttons based on context
539
- if (file) {
540
- // New file selected - show only reset (to go back to original)
541
- this.elements.reset.classList.remove('hidden');
542
- this.elements.remove.classList.add('hidden');
543
- } else if (isInitial && hasRealValue) {
544
- // Initial load with real value (oldValue from DB) - show remove only
545
- this.elements.reset.classList.add('hidden');
546
- if (this.cfg.showRemoveButton) {
547
- this.elements.remove.classList.remove('hidden');
548
- }
549
- } else if (isInitial) {
550
- // Initial load with default placeholder only - hide both
551
- this.elements.reset.classList.add('hidden');
552
- this.elements.remove.classList.add('hidden');
553
- }
554
- }
555
-
556
- _showPlaceholder() {
557
- this.elements.placeholder.classList.remove('hidden');
558
- this.elements.preview.classList.add('hidden');
559
- this.elements.info.classList.add('hidden');
560
- this.elements.reset.classList.add('hidden');
561
- this.elements.remove.classList.add('hidden');
562
- }
563
-
564
- _showProgress(percent) {
565
- this.elements.progress.classList.remove('hidden');
566
- this.elements.progressBar.value = percent;
567
- this.elements.progressText.textContent = `${Math.round(percent)}%`;
568
- }
569
-
570
- _hideProgress() {
571
- this.elements.progress.classList.add('hidden');
572
- this.elements.progressBar.value = 0;
573
- }
574
-
575
- _showError(message) {
576
- this.elements.error.classList.remove('hidden');
577
- this.elements.errorText.textContent = message;
578
- }
579
-
580
- _hideError() {
581
- this.elements.error.classList.add('hidden');
582
- }
583
-
584
- // ==================== PUBLIC API ====================
585
-
586
- /**
587
- * Get the current file
588
- */
589
- getFile() {
590
- return this._file;
591
- }
592
-
593
- /**
594
- * Get file info
595
- */
596
- getFileInfo() {
597
- if (!this._file) return null;
598
- return {
599
- name: this._file.name,
600
- size: this._file.size,
601
- type: this._file.type,
602
- formattedSize: this._formatSize(this._file.size)
603
- };
604
- }
605
-
606
- /**
607
- * Check if a file is selected
608
- */
609
- hasFile() {
610
- return this._file !== null;
611
- }
612
-
613
- /**
614
- * Get preview URL
615
- */
616
- getPreviewUrl() {
617
- return this._previewUrl;
618
- }
619
-
620
- /**
621
- * Clear the file input
622
- */
623
- clear() {
624
- this._file = null;
625
- this._previewUrl = null;
626
- this.originalInput.value = '';
627
-
628
- // Check if we should show default or blank
629
- if (this.cfg.defaultImage) {
630
- this._showPreview(this.cfg.defaultImage, null, true);
631
- this.elements.reset.classList.add('hidden');
632
- } else {
633
- this._showPlaceholder();
634
- }
635
-
636
- this._hideError();
637
- this._emit('clear');
638
- this._emit('change', { file: null });
639
- }
640
-
641
- /**
642
- * Reset to original/old value
643
- */
644
- reset() {
645
- this._file = null;
646
- this._previewUrl = null;
647
- this.originalInput.value = '';
648
-
649
- const originalUrl = this.cfg.oldValue || this.cfg.defaultImage;
650
-
651
- if (originalUrl) {
652
- this._showPreview(originalUrl, null, true);
653
- this.elements.reset.classList.add('hidden');
654
- } else {
655
- this._showPlaceholder();
656
- }
657
-
658
- this._hideError();
659
- this._emit('reset');
660
- this._emit('change', { file: null });
661
- }
662
-
663
- /**
664
- * Set a file programmatically
665
- */
666
- setFile(file) {
667
- if (file instanceof File) {
668
- this._processFile(file);
669
- }
670
- }
671
-
672
- /**
673
- * Set preview URL directly (for existing files)
674
- */
675
- setPreview(url) {
676
- if (url) {
677
- this._showPreview(url, null, true);
678
- this._previewUrl = url;
679
- }
680
- }
681
-
682
- /**
683
- * Upload file via AJAX
684
- */
685
- async upload() {
686
- if (!this._file || !this.cfg.uploadUrl) return null;
687
-
688
- this._isUploading = true;
689
- this._emit('uploadStart', { file: this._file });
690
-
691
- const formData = new FormData();
692
- const fieldName = this.cfg.uploadFieldName || this.originalInput.name || 'file';
693
- formData.append(fieldName, this._file);
694
-
695
- try {
696
- const response = await this._sendRequest(formData);
697
- this._isUploading = false;
698
- this._hideProgress();
699
-
700
- if (response.ok) {
701
- this._emit('uploadSuccess', { response: response.data });
702
- return response.data;
703
- } else {
704
- throw new Error(response.data?.message || this.cfg.translations.uploadError);
705
- }
706
- } catch (error) {
707
- this._isUploading = false;
708
- this._hideProgress();
709
- this._showError(error.message || this.cfg.translations.uploadError);
710
- this._emit('uploadError', { error });
711
- return null;
712
- }
713
- }
714
-
715
- /**
716
- * Remove file via AJAX
717
- */
718
- async remove() {
719
- if (!this.cfg.removeUrl) return false;
720
-
721
- try {
722
- const response = await this._sendRequest(null, 'DELETE', this.cfg.removeUrl);
723
-
724
- if (response.ok) {
725
- this._emit('removeSuccess', { response: response.data });
726
- return true;
727
- } else {
728
- throw new Error(response.data?.message || 'Removal failed');
729
- }
730
- } catch (error) {
731
- this._showError(error.message || 'Removal failed');
732
- this._emit('removeError', { error });
733
- return false;
734
- }
735
- }
736
-
737
- async _sendRequest(formData = null, method = null, url = null) {
738
- const headers = {
739
- 'Accept': 'application/json',
740
- 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
741
- };
742
-
743
- // Use XMLHttpRequest for progress tracking
744
- return new Promise((resolve, reject) => {
745
- const xhr = new XMLHttpRequest();
746
-
747
- if (formData) {
748
- xhr.upload.addEventListener('progress', (e) => {
749
- if (e.lengthComputable && this.cfg.showProgressBar) {
750
- const percent = (e.loaded / e.total) * 100;
751
- this._showProgress(percent);
752
- this._emit('uploadProgress', { loaded: e.loaded, total: e.total, percent });
753
- }
754
- });
755
- }
756
-
757
- xhr.addEventListener('load', () => {
758
- let data = {};
759
- try { data = JSON.parse(xhr.responseText); } catch { }
760
- resolve({
761
- ok: xhr.status >= 200 && xhr.status < 300,
762
- status: xhr.status,
763
- data
764
- });
765
- });
766
-
767
- xhr.addEventListener('error', () => reject(new Error('Network error')));
768
-
769
- xhr.open(method || this.cfg.uploadMethod, url || this.cfg.uploadUrl);
770
- for (const [key, value] of Object.entries(headers)) {
771
- xhr.setRequestHeader(key, value);
772
- }
773
- xhr.send(formData);
774
- });
775
- }
776
-
777
- /**
778
- * Enable the uploader
779
- */
780
- enable() {
781
- this.originalInput.disabled = false;
782
- this.elements.dropzone.classList.remove('opacity-50', 'pointer-events-none');
783
- this._emit('enable');
784
- }
785
-
786
- /**
787
- * Disable the uploader
788
- */
789
- disable() {
790
- this.originalInput.disabled = true;
791
- this.elements.dropzone.classList.add('opacity-50', 'pointer-events-none');
792
- this._emit('disable');
793
- }
794
-
795
- /**
796
- * Subscribe to events
797
- */
798
- on(event, handler) {
799
- if (!this._listeners[event]) {
800
- this._listeners[event] = new Set();
801
- }
802
- this._listeners[event].add(handler);
803
- return this;
804
- }
805
-
806
- /**
807
- * Unsubscribe from events
808
- */
809
- off(event, handler) {
810
- if (this._listeners[event]) {
811
- if (handler) {
812
- this._listeners[event].delete(handler);
813
- } else {
814
- this._listeners[event].clear();
815
- }
816
- }
817
- return this;
818
- }
819
-
820
- /**
821
- * Destroy instance
822
- */
823
- destroy() {
824
- // Remove event listeners
825
- this.originalInput.removeEventListener('change', this._boundHandlers.onInputChange);
826
- this.elements.dropzone.removeEventListener('click', this._boundHandlers.onDropzoneClick);
827
- this.elements.dropzone.removeEventListener('dragover', this._boundHandlers.onDragOver);
828
- this.elements.dropzone.removeEventListener('dragleave', this._boundHandlers.onDragLeave);
829
- this.elements.dropzone.removeEventListener('drop', this._boundHandlers.onDrop);
830
- this.elements.browse.removeEventListener('click', this._boundHandlers.onBrowse);
831
- this.elements.reset.removeEventListener('click', this._boundHandlers.onReset);
832
- this.elements.remove.removeEventListener('click', this._boundHandlers.onRemove);
833
-
834
- // Show original input
835
- this.originalInput.style.display = '';
836
- this.originalInput.classList.remove('ds-upload-input');
837
-
838
- // Remove container
839
- this.container.remove();
840
-
841
- // Clear state
842
- this._listeners = {};
843
- DSUpload.instances.delete(this.instanceId);
844
- delete this.wrapper.dataset.dsUploadId;
845
-
846
- this._emit('destroy');
847
- }
848
-
849
- // ==================== UTILITIES ====================
850
-
851
- _emit(event, detail = {}) {
852
- (this._listeners[event] || new Set()).forEach(fn => {
853
- try { fn(detail); } catch (err) { console.warn('DSUpload event error:', err); }
854
- });
855
-
856
- this.wrapper.dispatchEvent(new CustomEvent(`dsupload:${event}`, {
857
- bubbles: true,
858
- detail: { instance: this, ...detail }
859
- }));
860
- }
861
-
862
- _formatSize(bytes) {
863
- if (bytes === 0) return '0 Bytes';
864
-
865
- const k = 1024;
866
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
867
-
868
- if (this.cfg.sizeUnit !== 'auto') {
869
- const i = sizes.indexOf(this.cfg.sizeUnit);
870
- if (i > 0) {
871
- return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
872
- }
873
- }
874
-
875
- const i = Math.floor(Math.log(bytes) / Math.log(k));
876
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
877
- }
878
- }
879
-
880
- // Auto-init on DOM ready
881
- if (typeof document !== 'undefined') {
882
- if (document.readyState === 'loading') {
883
- document.addEventListener('DOMContentLoaded', () => DSUpload.initAll());
884
- } else {
885
- DSUpload.initAll();
886
- }
887
- }
1
+ /**
2
+ * DSUpload
3
+ *
4
+ * A comprehensive file upload component with:
5
+ * - Image/file preview
6
+ * - Progress bar support
7
+ * - File type validation
8
+ * - File size validation
9
+ * - Old value / default image support
10
+ * - Reset functionality
11
+ * - Drag and drop support
12
+ * - Full event system
13
+ */
14
+ export class DSUpload {
15
+ static instances = new Map();
16
+ static instanceCounter = 0;
17
+
18
+ /**
19
+ * Default Icons
20
+ */
21
+ static icons = {
22
+ upload: `<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>`,
23
+ file: `<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>`,
24
+ image: `<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>`,
25
+ close: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`,
26
+ reset: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>`,
27
+ check: `<svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>`
28
+ };
29
+
30
+ /**
31
+ * Default configuration
32
+ */
33
+ static defaults = {
34
+ // Preview options
35
+ preview: true, // Enable preview
36
+ previewMaxHeight: '200px', // Max height for preview image
37
+ defaultImage: null, // Default/placeholder image URL
38
+ oldValue: null, // Old/current file URL (for edit forms)
39
+
40
+ // File validation
41
+ accept: '*', // Accepted file types (e.g., 'image/*', '.pdf,.doc')
42
+ maxSize: 5 * 1024 * 1024, // Max file size in bytes (default 5MB)
43
+ minSize: 0, // Min file size in bytes
44
+
45
+ // UI options
46
+ showProgressBar: true, // Show upload progress bar
47
+ showFileInfo: true, // Show file name and size
48
+ showResetButton: true, // Show reset button when file selected
49
+ showRemoveButton: true, // Show remove/clear button
50
+ dropzone: true, // Enable drag and drop
51
+ dropzoneText: 'Drop file here or click to upload',
52
+ browseText: 'Browse',
53
+
54
+ // Styling
55
+ wrapperClass: '',
56
+ previewClass: '',
57
+ dropzoneClass: '',
58
+
59
+ // Behavior
60
+ autoUpload: false, // Auto upload on select (requires uploadUrl)
61
+ uploadUrl: null, // URL for AJAX upload
62
+ removeUrl: null, // URL for AJAX removal (DELETE request)
63
+ uploadMethod: 'POST', // Upload HTTP method
64
+ uploadFieldName: null, // Override form field name for upload
65
+
66
+ // Size display
67
+ sizeUnit: 'auto', // 'auto', 'KB', 'MB', 'GB'
68
+
69
+ // Translations
70
+ translations: {
71
+ dropzone: 'Drop file here or click to upload',
72
+ browse: 'Browse',
73
+ remove: 'Remove',
74
+ reset: 'Reset',
75
+ fileTooBig: 'File is too large. Maximum size is {max}',
76
+ fileTooSmall: 'File is too small. Minimum size is {min}',
77
+ invalidType: 'Invalid file type. Accepted types: {types}',
78
+ uploadError: 'Upload failed',
79
+ uploading: 'Uploading...'
80
+ }
81
+ };
82
+
83
+ /**
84
+ * @param {string|HTMLElement} selector - Input element or selector
85
+ * @param {Object} config - Configuration options
86
+ */
87
+ constructor(selector, config = {}) {
88
+ this.instanceId = `ds-upload-${++DSUpload.instanceCounter}`;
89
+
90
+ // Find the input element
91
+ const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
92
+
93
+ if (!el) {
94
+ throw new Error('DSUpload: Element not found.');
95
+ }
96
+
97
+ // Store original input
98
+ this.originalInput = el.tagName === 'INPUT' ? el : el.querySelector('input[type="file"]');
99
+ if (!this.originalInput) {
100
+ throw new Error('DSUpload: No file input found.');
101
+ }
102
+
103
+ // Find or create wrapper
104
+ this.wrapper = el.closest('.form-group') || el.parentElement;
105
+
106
+ // Merge config with data attributes
107
+ this.cfg = this._buildConfig(config);
108
+
109
+ // State
110
+ this._file = null;
111
+ this._previewUrl = null;
112
+ this._isUploading = false;
113
+ this._progress = 0;
114
+ this._originalValue = this.cfg.oldValue || this.cfg.defaultImage;
115
+
116
+ // Event listeners
117
+ this._listeners = {};
118
+ this._boundHandlers = {};
119
+
120
+ // Initialize
121
+ this._init();
122
+
123
+ // Register instance
124
+ DSUpload.instances.set(this.instanceId, this);
125
+ this.wrapper.dataset.dsUploadId = this.instanceId;
126
+ }
127
+
128
+ /**
129
+ * Static factory
130
+ */
131
+ static create(selector, config = {}) {
132
+ return new DSUpload(selector, config);
133
+ }
134
+
135
+ /**
136
+ * Get instance by element
137
+ */
138
+ static getInstance(element) {
139
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
140
+ if (!el) return null;
141
+ const wrapper = el.closest('[data-ds-upload-id]');
142
+ if (!wrapper) return null;
143
+ return DSUpload.instances.get(wrapper.dataset.dsUploadId);
144
+ }
145
+
146
+ /**
147
+ * Auto-initialize all elements with [data-ds-upload]
148
+ */
149
+ static initAll(selector = '[data-ds-upload]') {
150
+ document.querySelectorAll(selector).forEach(el => {
151
+ if (!el.closest('[data-ds-upload-id]')) {
152
+ new DSUpload(el);
153
+ }
154
+ });
155
+ }
156
+
157
+ // ==================== INITIALIZATION ====================
158
+
159
+ _buildConfig(userConfig) {
160
+ const dataConfig = this._parseDataAttributes();
161
+ return { ...DSUpload.defaults, ...dataConfig, ...userConfig };
162
+ }
163
+
164
+ _parseDataAttributes() {
165
+ const data = this.originalInput.dataset;
166
+ const config = {};
167
+
168
+ if (data.preview !== undefined) config.preview = data.preview !== 'false';
169
+ if (data.defaultImage) config.defaultImage = data.defaultImage;
170
+ if (data.fallbackImage) config.fallbackImage = data.fallbackImage;
171
+ if (data.oldValue) config.oldValue = data.oldValue;
172
+ if (data.accept) config.accept = data.accept;
173
+ if (data.maxSize) config.maxSize = parseInt(data.maxSize, 10);
174
+ if (data.minSize) config.minSize = parseInt(data.minSize, 10);
175
+ if (data.dropzone !== undefined) config.dropzone = data.dropzone !== 'false';
176
+ if (data.showProgressBar !== undefined) config.showProgressBar = data.showProgressBar !== 'false';
177
+ if (data.uploadUrl) config.uploadUrl = data.uploadUrl;
178
+ if (data.removeUrl) config.removeUrl = data.removeUrl;
179
+
180
+ return config;
181
+ }
182
+
183
+ _init() {
184
+ this._buildDOM();
185
+ this._cacheElements();
186
+ this._bindEvents();
187
+ this._setInitialPreview();
188
+
189
+ if (this.cfg.accept && this.cfg.accept !== '*') {
190
+ this.originalInput.setAttribute('accept', this.cfg.accept);
191
+ }
192
+ }
193
+
194
+ _buildDOM() {
195
+ // Hide original input
196
+ this.originalInput.style.display = 'none';
197
+ this.originalInput.classList.add('ds-upload-input');
198
+
199
+ // Create upload UI
200
+ const container = document.createElement('div');
201
+ container.className = `ds-upload-container relative ${this.cfg.wrapperClass}`;
202
+ container.innerHTML = `
203
+ <!-- Dropzone / Upload Area -->
204
+ <div class="ds-upload-dropzone border-2 border-dashed border-base-300 rounded-sm p-3 pb-1 text-center cursor-pointer transition-all hover:border-primary hover:bg-base-200/50 ${this.cfg.dropzoneClass}"
205
+ data-ds-upload-dropzone>
206
+ <div class="ds-upload-placeholder flex flex-col items-center gap-2" data-ds-upload-placeholder>
207
+ <span class="text-base-content/40">${DSUpload.icons.upload}</span>
208
+ <p class="text-sm text-base-content/60">${this.cfg.translations.dropzone}</p>
209
+ <button type="button" class="btn btn-primary btn-sm mt-2" data-ds-upload-browse>
210
+ ${this.cfg.translations.browse}
211
+ </button>
212
+ </div>
213
+
214
+ <!-- Preview Area -->
215
+ <div class="ds-upload-preview hidden" data-ds-upload-preview>
216
+ <div class="relative inline-block">
217
+ <img src="" alt="Preview"
218
+ class="max-h-[${this.cfg.previewMaxHeight}] max-w-full rounded-lg object-contain mx-auto ${this.cfg.previewClass}"
219
+ data-ds-upload-preview-img>
220
+ <div class="ds-upload-file-preview hidden flex-col items-center gap-2" data-ds-upload-file-preview>
221
+ <span class="text-base-content/40">${DSUpload.icons.file}</span>
222
+ <span class="text-sm font-medium" data-ds-upload-filename></span>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- File Info -->
227
+ <div class="ds-upload-info mt-3 text-center" data-ds-upload-info>
228
+ <p class="text-sm font-medium text-base-content" data-ds-upload-name></p>
229
+ <p class="text-xs text-base-content/60" data-ds-upload-size></p>
230
+ </div>
231
+
232
+ <!-- Action Buttons -->
233
+ <div class="ds-upload-actions flex justify-center gap-2 mt-3">
234
+ <button type="button" class="btn btn-ghost btn-sm gap-1" data-ds-upload-reset title="${this.cfg.translations.reset}">
235
+ ${DSUpload.icons.reset}
236
+ <span>${this.cfg.translations.reset}</span>
237
+ </button>
238
+ <button type="button" class="btn btn-ghost btn-sm text-error gap-1" data-ds-upload-remove title="${this.cfg.translations.remove}">
239
+ ${DSUpload.icons.close}
240
+ <span>${this.cfg.translations.remove}</span>
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Progress Bar -->
246
+ <div class="ds-upload-progress hidden mt-4" data-ds-upload-progress>
247
+ <div class="flex justify-between text-xs text-base-content/60 mb-1">
248
+ <span>${this.cfg.translations.uploading}</span>
249
+ <span data-ds-upload-progress-text>0%</span>
250
+ </div>
251
+ <progress class="progress progress-primary w-full" value="0" max="100" data-ds-upload-progress-bar></progress>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Error Message -->
256
+ <div class="ds-upload-error hidden mt-2" data-ds-upload-error>
257
+ <p class="text-sm text-error flex items-center gap-1">
258
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
259
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
260
+ </svg>
261
+ <span data-ds-upload-error-text></span>
262
+ </p>
263
+ </div>
264
+ `;
265
+
266
+ // Insert after original input
267
+ this.originalInput.insertAdjacentElement('afterend', container);
268
+ this.container = container;
269
+ }
270
+
271
+ _cacheElements() {
272
+ this.elements = {
273
+ dropzone: this.container.querySelector('[data-ds-upload-dropzone]'),
274
+ placeholder: this.container.querySelector('[data-ds-upload-placeholder]'),
275
+ preview: this.container.querySelector('[data-ds-upload-preview]'),
276
+ previewImg: this.container.querySelector('[data-ds-upload-preview-img]'),
277
+ filePreview: this.container.querySelector('[data-ds-upload-file-preview]'),
278
+ filename: this.container.querySelector('[data-ds-upload-filename]'),
279
+ info: this.container.querySelector('[data-ds-upload-info]'),
280
+ name: this.container.querySelector('[data-ds-upload-name]'),
281
+ size: this.container.querySelector('[data-ds-upload-size]'),
282
+ reset: this.container.querySelector('[data-ds-upload-reset]'),
283
+ remove: this.container.querySelector('[data-ds-upload-remove]'),
284
+ browse: this.container.querySelector('[data-ds-upload-browse]'),
285
+ progress: this.container.querySelector('[data-ds-upload-progress]'),
286
+ progressBar: this.container.querySelector('[data-ds-upload-progress-bar]'),
287
+ progressText: this.container.querySelector('[data-ds-upload-progress-text]'),
288
+ error: this.container.querySelector('[data-ds-upload-error]'),
289
+ errorText: this.container.querySelector('[data-ds-upload-error-text]')
290
+ };
291
+ }
292
+
293
+ _bindEvents() {
294
+ this._boundHandlers = {
295
+ onInputChange: this._onInputChange.bind(this),
296
+ onDropzoneClick: this._onDropzoneClick.bind(this),
297
+ onDragOver: this._onDragOver.bind(this),
298
+ onDragLeave: this._onDragLeave.bind(this),
299
+ onDrop: this._onDrop.bind(this),
300
+ onReset: this._onReset.bind(this),
301
+ onRemove: this._onRemove.bind(this),
302
+ onBrowse: this._onBrowse.bind(this)
303
+ };
304
+
305
+ // Input change
306
+ this.originalInput.addEventListener('change', this._boundHandlers.onInputChange);
307
+
308
+ // Dropzone click
309
+ this.elements.dropzone.addEventListener('click', this._boundHandlers.onDropzoneClick);
310
+
311
+ // Drag and drop
312
+ if (this.cfg.dropzone) {
313
+ this.elements.dropzone.addEventListener('dragover', this._boundHandlers.onDragOver);
314
+ this.elements.dropzone.addEventListener('dragleave', this._boundHandlers.onDragLeave);
315
+ this.elements.dropzone.addEventListener('drop', this._boundHandlers.onDrop);
316
+ }
317
+
318
+ // Buttons
319
+ this.elements.browse.addEventListener('click', this._boundHandlers.onBrowse);
320
+ this.elements.reset.addEventListener('click', this._boundHandlers.onReset);
321
+ this.elements.remove.addEventListener('click', this._boundHandlers.onRemove);
322
+ }
323
+
324
+ _setInitialPreview() {
325
+ const initialUrl = this.cfg.oldValue || this.cfg.defaultImage;
326
+ // Treat removeUrl as proof of a real value existing on the server
327
+ const hasRealValue = !!this.cfg.oldValue || !!this.cfg.removeUrl;
328
+
329
+ if (initialUrl && this.cfg.preview) {
330
+ this._showPreview(initialUrl, null, true, hasRealValue);
331
+ }
332
+
333
+ // Hide reset button initially (no new file selected yet)
334
+ this.elements.reset.classList.add('hidden');
335
+
336
+ // Hide remove button if only showing default placeholder logic handled in _showPreview
337
+ // based on hasRealValue passed above.
338
+
339
+ this._emit('ready', { hasInitialValue: !!initialUrl, hasRealValue });
340
+ }
341
+
342
+ // ==================== EVENT HANDLERS ====================
343
+
344
+ _onInputChange(e) {
345
+ const file = e.target.files[0];
346
+ if (file) {
347
+ this._processFile(file);
348
+ }
349
+ }
350
+
351
+ _onDropzoneClick(e) {
352
+ // Don't trigger if clicking buttons
353
+ if (e.target.closest('button')) return;
354
+ this.originalInput.click();
355
+ }
356
+
357
+ _onBrowse(e) {
358
+ e.stopPropagation();
359
+ this.originalInput.click();
360
+ }
361
+
362
+ _onDragOver(e) {
363
+ e.preventDefault();
364
+ e.stopPropagation();
365
+ this.elements.dropzone.classList.add('border-primary', 'bg-primary/10');
366
+ }
367
+
368
+ _onDragLeave(e) {
369
+ e.preventDefault();
370
+ e.stopPropagation();
371
+ this.elements.dropzone.classList.remove('border-primary', 'bg-primary/10');
372
+ }
373
+
374
+ _onDrop(e) {
375
+ e.preventDefault();
376
+ e.stopPropagation();
377
+ this.elements.dropzone.classList.remove('border-primary', 'bg-primary/10');
378
+
379
+ const file = e.dataTransfer.files[0];
380
+ if (file) {
381
+ this._processFile(file);
382
+ }
383
+ }
384
+
385
+ _onReset(e) {
386
+ e.stopPropagation();
387
+ this.reset();
388
+ }
389
+
390
+ async _onRemove(e) {
391
+ e.stopPropagation();
392
+
393
+ // If we have a removeUrl and a real value (implied by removeUrl presence), try to delete via AJAX
394
+ // validation: ensure we don't have a new file selected (this._file should be null)
395
+ if (this.cfg.removeUrl && !this._file) {
396
+ if (confirm('Are you sure you want to remove this file?')) {
397
+ const success = await this.remove();
398
+ if (success) {
399
+ // File is deleted from server.
400
+ // If we have a fallback image, set it as the new default
401
+ // Otherwise clear default so we show placeholder
402
+ this.cfg.defaultImage = this.cfg.fallbackImage || null;
403
+
404
+ this.cfg.oldValue = null;
405
+ // Also clear removeUrl to prevent trying to delete again
406
+ this.cfg.removeUrl = null;
407
+
408
+ this.clear();
409
+ }
410
+ }
411
+ } else {
412
+ this.clear();
413
+ }
414
+ }
415
+
416
+ // ==================== FILE PROCESSING ====================
417
+
418
+ _processFile(file) {
419
+ // Clear previous errors
420
+ this._hideError();
421
+
422
+ // Validate file type
423
+ if (!this._validateType(file)) {
424
+ this._showError(
425
+ this.cfg.translations.invalidType.replace('{types}', this.cfg.accept)
426
+ );
427
+ this._emit('error', { error: 'invalid_type', file });
428
+ return false;
429
+ }
430
+
431
+ // Validate file size
432
+ if (!this._validateSize(file)) {
433
+ if (file.size > this.cfg.maxSize) {
434
+ this._showError(
435
+ this.cfg.translations.fileTooBig.replace('{max}', this._formatSize(this.cfg.maxSize))
436
+ );
437
+ } else {
438
+ this._showError(
439
+ this.cfg.translations.fileTooSmall.replace('{min}', this._formatSize(this.cfg.minSize))
440
+ );
441
+ }
442
+ this._emit('error', { error: 'invalid_size', file });
443
+ return false;
444
+ }
445
+
446
+ // Store file
447
+ this._file = file;
448
+
449
+ // Update input
450
+ const dt = new DataTransfer();
451
+ dt.items.add(file);
452
+ this.originalInput.files = dt.files;
453
+
454
+ // Show preview
455
+ if (this.cfg.preview) {
456
+ if (this._isImage(file)) {
457
+ const reader = new FileReader();
458
+ reader.onload = (e) => {
459
+ this._showPreview(e.target.result, file);
460
+ };
461
+ reader.readAsDataURL(file);
462
+ } else {
463
+ this._showPreview(null, file);
464
+ }
465
+ }
466
+
467
+ // Auto upload
468
+ if (this.cfg.autoUpload && this.cfg.uploadUrl) {
469
+ this.upload();
470
+ }
471
+
472
+ this._emit('select', { file });
473
+ return true;
474
+ }
475
+
476
+ _validateType(file) {
477
+ if (!this.cfg.accept || this.cfg.accept === '*') return true;
478
+
479
+ const accept = this.cfg.accept.toLowerCase().split(',').map(t => t.trim());
480
+ const fileType = file.type.toLowerCase();
481
+ const fileExt = '.' + file.name.split('.').pop().toLowerCase();
482
+
483
+ return accept.some(type => {
484
+ if (type.endsWith('/*')) {
485
+ // Wildcard like image/*
486
+ return fileType.startsWith(type.replace('/*', '/'));
487
+ }
488
+ if (type.startsWith('.')) {
489
+ // Extension like .pdf
490
+ return fileExt === type;
491
+ }
492
+ // Exact MIME type
493
+ return fileType === type;
494
+ });
495
+ }
496
+
497
+ _validateSize(file) {
498
+ if (this.cfg.maxSize && file.size > this.cfg.maxSize) return false;
499
+ if (this.cfg.minSize && file.size < this.cfg.minSize) return false;
500
+ return true;
501
+ }
502
+
503
+ _isImage(file) {
504
+ return file.type.startsWith('image/');
505
+ }
506
+
507
+ // ==================== UI UPDATES ====================
508
+
509
+ _showPreview(url, file, isInitial = false, hasRealValue = false) {
510
+ this.elements.placeholder.classList.add('hidden');
511
+ this.elements.preview.classList.remove('hidden');
512
+
513
+ if (url && (isInitial || (file && this._isImage(file)))) {
514
+ // Image preview
515
+ this.elements.previewImg.src = url;
516
+ this.elements.previewImg.classList.remove('hidden');
517
+ this.elements.filePreview.classList.add('hidden');
518
+ this._previewUrl = url;
519
+ } else {
520
+ // File icon preview
521
+ this.elements.previewImg.classList.add('hidden');
522
+ this.elements.filePreview.classList.remove('hidden');
523
+ this.elements.filePreview.classList.add('flex');
524
+ if (file) {
525
+ this.elements.filename.textContent = file.name;
526
+ }
527
+ }
528
+
529
+ // File info
530
+ if (file && this.cfg.showFileInfo) {
531
+ this.elements.info.classList.remove('hidden');
532
+ this.elements.name.textContent = file.name;
533
+ this.elements.size.textContent = this._formatSize(file.size);
534
+ } else if (isInitial) {
535
+ this.elements.info.classList.add('hidden');
536
+ }
537
+
538
+ // Show/hide buttons based on context
539
+ if (file) {
540
+ // New file selected - show only reset (to go back to original)
541
+ this.elements.reset.classList.remove('hidden');
542
+ this.elements.remove.classList.add('hidden');
543
+ } else if (isInitial && hasRealValue) {
544
+ // Initial load with real value (oldValue from DB) - show remove only
545
+ this.elements.reset.classList.add('hidden');
546
+ if (this.cfg.showRemoveButton) {
547
+ this.elements.remove.classList.remove('hidden');
548
+ }
549
+ } else if (isInitial) {
550
+ // Initial load with default placeholder only - hide both
551
+ this.elements.reset.classList.add('hidden');
552
+ this.elements.remove.classList.add('hidden');
553
+ }
554
+ }
555
+
556
+ _showPlaceholder() {
557
+ this.elements.placeholder.classList.remove('hidden');
558
+ this.elements.preview.classList.add('hidden');
559
+ this.elements.info.classList.add('hidden');
560
+ this.elements.reset.classList.add('hidden');
561
+ this.elements.remove.classList.add('hidden');
562
+ }
563
+
564
+ _showProgress(percent) {
565
+ this.elements.progress.classList.remove('hidden');
566
+ this.elements.progressBar.value = percent;
567
+ this.elements.progressText.textContent = `${Math.round(percent)}%`;
568
+ }
569
+
570
+ _hideProgress() {
571
+ this.elements.progress.classList.add('hidden');
572
+ this.elements.progressBar.value = 0;
573
+ }
574
+
575
+ _showError(message) {
576
+ this.elements.error.classList.remove('hidden');
577
+ this.elements.errorText.textContent = message;
578
+ }
579
+
580
+ _hideError() {
581
+ this.elements.error.classList.add('hidden');
582
+ }
583
+
584
+ // ==================== PUBLIC API ====================
585
+
586
+ /**
587
+ * Get the current file
588
+ */
589
+ getFile() {
590
+ return this._file;
591
+ }
592
+
593
+ /**
594
+ * Get file info
595
+ */
596
+ getFileInfo() {
597
+ if (!this._file) return null;
598
+ return {
599
+ name: this._file.name,
600
+ size: this._file.size,
601
+ type: this._file.type,
602
+ formattedSize: this._formatSize(this._file.size)
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Check if a file is selected
608
+ */
609
+ hasFile() {
610
+ return this._file !== null;
611
+ }
612
+
613
+ /**
614
+ * Get preview URL
615
+ */
616
+ getPreviewUrl() {
617
+ return this._previewUrl;
618
+ }
619
+
620
+ /**
621
+ * Clear the file input
622
+ */
623
+ clear() {
624
+ this._file = null;
625
+ this._previewUrl = null;
626
+ this.originalInput.value = '';
627
+
628
+ // Check if we should show default or blank
629
+ if (this.cfg.defaultImage) {
630
+ this._showPreview(this.cfg.defaultImage, null, true);
631
+ this.elements.reset.classList.add('hidden');
632
+ } else {
633
+ this._showPlaceholder();
634
+ }
635
+
636
+ this._hideError();
637
+ this._emit('clear');
638
+ this._emit('change', { file: null });
639
+ }
640
+
641
+ /**
642
+ * Reset to original/old value
643
+ */
644
+ reset() {
645
+ this._file = null;
646
+ this._previewUrl = null;
647
+ this.originalInput.value = '';
648
+
649
+ const originalUrl = this.cfg.oldValue || this.cfg.defaultImage;
650
+
651
+ if (originalUrl) {
652
+ this._showPreview(originalUrl, null, true);
653
+ this.elements.reset.classList.add('hidden');
654
+ } else {
655
+ this._showPlaceholder();
656
+ }
657
+
658
+ this._hideError();
659
+ this._emit('reset');
660
+ this._emit('change', { file: null });
661
+ }
662
+
663
+ /**
664
+ * Set a file programmatically
665
+ */
666
+ setFile(file) {
667
+ if (file instanceof File) {
668
+ this._processFile(file);
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Set preview URL directly (for existing files)
674
+ */
675
+ setPreview(url) {
676
+ if (url) {
677
+ this._showPreview(url, null, true);
678
+ this._previewUrl = url;
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Upload file via AJAX
684
+ */
685
+ async upload() {
686
+ if (!this._file || !this.cfg.uploadUrl) return null;
687
+
688
+ this._isUploading = true;
689
+ this._emit('uploadStart', { file: this._file });
690
+
691
+ const formData = new FormData();
692
+ const fieldName = this.cfg.uploadFieldName || this.originalInput.name || 'file';
693
+ formData.append(fieldName, this._file);
694
+
695
+ try {
696
+ const response = await this._sendRequest(formData);
697
+ this._isUploading = false;
698
+ this._hideProgress();
699
+
700
+ if (response.ok) {
701
+ this._emit('uploadSuccess', { response: response.data });
702
+ return response.data;
703
+ } else {
704
+ throw new Error(response.data?.message || this.cfg.translations.uploadError);
705
+ }
706
+ } catch (error) {
707
+ this._isUploading = false;
708
+ this._hideProgress();
709
+ this._showError(error.message || this.cfg.translations.uploadError);
710
+ this._emit('uploadError', { error });
711
+ return null;
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Remove file via AJAX
717
+ */
718
+ async remove() {
719
+ if (!this.cfg.removeUrl) return false;
720
+
721
+ try {
722
+ const response = await this._sendRequest(null, 'DELETE', this.cfg.removeUrl);
723
+
724
+ if (response.ok) {
725
+ this._emit('removeSuccess', { response: response.data });
726
+ return true;
727
+ } else {
728
+ throw new Error(response.data?.message || 'Removal failed');
729
+ }
730
+ } catch (error) {
731
+ this._showError(error.message || 'Removal failed');
732
+ this._emit('removeError', { error });
733
+ return false;
734
+ }
735
+ }
736
+
737
+ async _sendRequest(formData = null, method = null, url = null) {
738
+ const headers = {
739
+ 'Accept': 'application/json',
740
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
741
+ };
742
+
743
+ // Use XMLHttpRequest for progress tracking
744
+ return new Promise((resolve, reject) => {
745
+ const xhr = new XMLHttpRequest();
746
+
747
+ if (formData) {
748
+ xhr.upload.addEventListener('progress', (e) => {
749
+ if (e.lengthComputable && this.cfg.showProgressBar) {
750
+ const percent = (e.loaded / e.total) * 100;
751
+ this._showProgress(percent);
752
+ this._emit('uploadProgress', { loaded: e.loaded, total: e.total, percent });
753
+ }
754
+ });
755
+ }
756
+
757
+ xhr.addEventListener('load', () => {
758
+ let data = {};
759
+ try { data = JSON.parse(xhr.responseText); } catch { }
760
+ resolve({
761
+ ok: xhr.status >= 200 && xhr.status < 300,
762
+ status: xhr.status,
763
+ data
764
+ });
765
+ });
766
+
767
+ xhr.addEventListener('error', () => reject(new Error('Network error')));
768
+
769
+ xhr.open(method || this.cfg.uploadMethod, url || this.cfg.uploadUrl);
770
+ for (const [key, value] of Object.entries(headers)) {
771
+ xhr.setRequestHeader(key, value);
772
+ }
773
+ xhr.send(formData);
774
+ });
775
+ }
776
+
777
+ /**
778
+ * Enable the uploader
779
+ */
780
+ enable() {
781
+ this.originalInput.disabled = false;
782
+ this.elements.dropzone.classList.remove('opacity-50', 'pointer-events-none');
783
+ this._emit('enable');
784
+ }
785
+
786
+ /**
787
+ * Disable the uploader
788
+ */
789
+ disable() {
790
+ this.originalInput.disabled = true;
791
+ this.elements.dropzone.classList.add('opacity-50', 'pointer-events-none');
792
+ this._emit('disable');
793
+ }
794
+
795
+ /**
796
+ * Subscribe to events
797
+ */
798
+ on(event, handler) {
799
+ if (!this._listeners[event]) {
800
+ this._listeners[event] = new Set();
801
+ }
802
+ this._listeners[event].add(handler);
803
+ return this;
804
+ }
805
+
806
+ /**
807
+ * Unsubscribe from events
808
+ */
809
+ off(event, handler) {
810
+ if (this._listeners[event]) {
811
+ if (handler) {
812
+ this._listeners[event].delete(handler);
813
+ } else {
814
+ this._listeners[event].clear();
815
+ }
816
+ }
817
+ return this;
818
+ }
819
+
820
+ /**
821
+ * Destroy instance
822
+ */
823
+ destroy() {
824
+ // Remove event listeners
825
+ this.originalInput.removeEventListener('change', this._boundHandlers.onInputChange);
826
+ this.elements.dropzone.removeEventListener('click', this._boundHandlers.onDropzoneClick);
827
+ this.elements.dropzone.removeEventListener('dragover', this._boundHandlers.onDragOver);
828
+ this.elements.dropzone.removeEventListener('dragleave', this._boundHandlers.onDragLeave);
829
+ this.elements.dropzone.removeEventListener('drop', this._boundHandlers.onDrop);
830
+ this.elements.browse.removeEventListener('click', this._boundHandlers.onBrowse);
831
+ this.elements.reset.removeEventListener('click', this._boundHandlers.onReset);
832
+ this.elements.remove.removeEventListener('click', this._boundHandlers.onRemove);
833
+
834
+ // Show original input
835
+ this.originalInput.style.display = '';
836
+ this.originalInput.classList.remove('ds-upload-input');
837
+
838
+ // Remove container
839
+ this.container.remove();
840
+
841
+ // Clear state
842
+ this._listeners = {};
843
+ DSUpload.instances.delete(this.instanceId);
844
+ delete this.wrapper.dataset.dsUploadId;
845
+
846
+ this._emit('destroy');
847
+ }
848
+
849
+ // ==================== UTILITIES ====================
850
+
851
+ _emit(event, detail = {}) {
852
+ (this._listeners[event] || new Set()).forEach(fn => {
853
+ try { fn(detail); } catch (err) { console.warn('DSUpload event error:', err); }
854
+ });
855
+
856
+ this.wrapper.dispatchEvent(new CustomEvent(`dsupload:${event}`, {
857
+ bubbles: true,
858
+ detail: { instance: this, ...detail }
859
+ }));
860
+ }
861
+
862
+ _formatSize(bytes) {
863
+ if (bytes === 0) return '0 Bytes';
864
+
865
+ const k = 1024;
866
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
867
+
868
+ if (this.cfg.sizeUnit !== 'auto') {
869
+ const i = sizes.indexOf(this.cfg.sizeUnit);
870
+ if (i > 0) {
871
+ return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
872
+ }
873
+ }
874
+
875
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
876
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
877
+ }
878
+ }
879
+
880
+ // Auto-init on DOM ready
881
+ if (typeof document !== 'undefined') {
882
+ if (document.readyState === 'loading') {
883
+ document.addEventListener('DOMContentLoaded', () => DSUpload.initAll());
884
+ } else {
885
+ DSUpload.initAll();
886
+ }
887
+ }