1import { Controller } from "@hotwired/stimulus" 2import { AppOverlay } from "ui/app_dialog" 3 4// Connects to data-controller="file-upload" 5// Replaces the legacy FileUpload class that used jQuery 6export default class extends Controller { 7 static targets = ["input", "thumbnails", "dropzone"] 8 static values = { 9 attribute: String, 10 insideId: String, 11 entity: String, 12 createUrl: String, 13 indexUrl: String, 14 deleteUrl: String, 15 previewW: Number, 16 previewH: Number, 17 varWidth: Boolean, 18 multiple: Boolean 19 } 20 21 connect() { 22 // Prevent default drag and drop behavior 23 this.boundPreventDefault = (e) => e.preventDefault() 24 document.addEventListener('drop', this.boundPreventDefault) 25 document.addEventListener('dragover', this.boundPreventDefault) 26 27 // Determine if multiple uploads are allowed 28 const isMultiple = this.multipleValue || 29 this.attributeValue === "menu_sponsor" || 30 this.attributeValue === "additional_images" 31 32 this.config = { 33 previewW: this.previewWValue || 150, 34 previewH: this.previewHValue || 150, 35 varWidth: this.varWidthValue || false, 36 multiple: isMultiple 37 } 38 39 // Track upload state 40 this.uploadingCount = 0 41 42 // Create dropzone 43 this.createDropZone() 44 45 // Load existing attachments 46 this.loadCmsAttachment() 47 } 48 49 disconnect() { 50 if (this.boundPreventDefault) { 51 document.removeEventListener('drop', this.boundPreventDefault) 52 document.removeEventListener('dragover', this.boundPreventDefault) 53 } 54 } 55 56 createDropZone() { 57 // Find input - try multiple methods 58 let input = this.hasInputTarget ? this.inputTarget : null 59 60 if (!input) { 61 // Try by target attribute 62 input = this.element.querySelector('input[data-file-upload-target="input"]') 63 } 64 65 if (!input) { 66 // Try by class 67 input = this.element.querySelector('input.cloudinary-fileupload') 68 } 69 70 if (!input) { 71 // Try any file input 72 input = this.element.querySelector('input[type="file"]') 73 } 74 75 if (!input) { 76 // Try any input at all 77 input = this.element.querySelector('input') 78 } 79 80 if (!input) { 81 console.error('FileUpload: Input element not found') 82 return 83 } 84 85 // Check if dropzone already exists 86 let dropzone = this.element.querySelector('.dropzone') 87 if (!dropzone) { 88 dropzone = document.createElement('div') 89 dropzone.className = `dropzone ${this.config.previewW === this.config.previewH && this.config.previewW > 1 && !this.config.varWidth ? 'square' : ''}` 90 91 // Create text container 92 const dropzoneText = document.createElement('div') 93 dropzoneText.className = 'dropzone-text' 94 dropzoneText.textContent = 'Drop file here or click to upload' 95 dropzone.appendChild(dropzoneText) 96 97 // Create loading indicator (hidden by default) 98 const loadingIndicator = document.createElement('div') 99 loadingIndicator.className = 'dropzone-loading' 100 loadingIndicator.innerHTML = ` 101 <div class="dropzone-spinner"></div> 102 <div class="dropzone-loading-text">Uploading...</div> 103 ` 104 dropzone.appendChild(loadingIndicator) 105 106 // Insert dropzone after input 107 input.parentNode.insertBefore(dropzone, input.nextSibling) 108 } 109 110 // Store dropzone reference 111 this.dropzone = dropzone 112 113 // Input is already hidden by CSS (prevents FOUC) 114 // Just ensure it stays hidden and is accessible for form submission 115 input.style.position = 'absolute' 116 input.style.width = '1px' 117 input.style.height = '1px' 118 input.style.opacity = '0' 119 input.style.pointerEvents = 'none' 120 121 // Ensure dropzone is visible (CSS handles initial state) 122 123 // Set up Cloudinary upload widget 124 this.setupCloudinaryUpload(dropzone, input) 125 } 126 127 setupCloudinaryUpload(dropzone, input) { 128 // Wait for Cloudinary widget to load if it's not ready yet 129 if (typeof cloudinary === 'undefined' || !cloudinary.openUploadWidget) { 130 // Wait for the widget script to load 131 const checkCloudinary = setInterval(() => { 132 if (typeof cloudinary !== 'undefined' && cloudinary.openUploadWidget) { 133 clearInterval(checkCloudinary) 134 this.setupWidget(dropzone, input) 135 } 136 }, 100) 137 138 // Timeout after 5 seconds 139 setTimeout(() => { 140 clearInterval(checkCloudinary) 141 if (typeof cloudinary === 'undefined' || !cloudinary.openUploadWidget) { 142 console.error('FileUpload: Cloudinary widget failed to load, falling back to native input') 143 if (input) { 144 input.style.display = 'block' 145 dropzone.style.display = 'none' 146 } 147 } 148 }, 5000) 149 150 return 151 } 152 153 this.setupWidget(dropzone, input) 154 } 155 156 setupWidget(dropzone, input) { 157 // Get Cloudinary config from window 158 const cloudName = window.CLOUDINARY_CLOUD_NAME 159 160 if (!cloudName) { 161 console.error('FileUpload: Cloudinary cloud name not configured') 162 // Fallback to native input 163 if (input) { 164 input.style.display = 'block' 165 dropzone.style.display = 'none' 166 } 167 return 168 } 169 170 const uploadPreset = window.CLOUDINARY_UPLOAD_PRESET 171 172 // Store widget config for later use 173 this.widgetConfig = { 174 cloudName: cloudName, 175 uploadPreset: uploadPreset, 176 multiple: this.config.multiple, 177 sources: ['local', 'camera', 'url'], 178 showAdvancedOptions: false, 179 cropping: false, 180 folder: this.attributeValue || 'uploads' 181 } 182 183 // Open widget on dropzone click 184 dropzone.addEventListener('click', (e) => { 185 e.preventDefault() 186 this.openUploadWidget() 187 }) 188 189 // Handle drag and drop - upload directly to Cloudinary 190 dropzone.addEventListener('dragover', (e) => { 191 e.preventDefault() 192 dropzone.classList.add('dragover') 193 }) 194 195 dropzone.addEventListener('dragleave', () => { 196 dropzone.classList.remove('dragover') 197 }) 198 199 dropzone.addEventListener('drop', async (e) => { 200 e.preventDefault() 201 dropzone.classList.remove('dragover') 202 203 const files = Array.from(e.dataTransfer.files) 204 if (files.length > 0) { 205 // Upload files directly to Cloudinary 206 for (const file of files) { 207 await this.uploadFileDirectly(file) 208 } 209 } 210 }) 211 } 212 213 openUploadWidget() { 214 if (typeof cloudinary === 'undefined' || !cloudinary.openUploadWidget) { 215 console.error('FileUpload: Cloudinary widget not available') 216 return 217 } 218 219 // If no upload preset is configured, show error 220 if (!this.widgetConfig.uploadPreset) { 221 console.error('FileUpload: Upload preset not configured. Please create an unsigned upload preset in Cloudinary or configure CLOUDINARY_UPLOAD_PRESET.') 222 void AppOverlay.alert({ 223 title: "Upload unavailable", 224 message: "File upload is not configured. Please contact an administrator to set up Cloudinary upload preset." 225 }) 226 return 227 } 228 229 cloudinary.openUploadWidget( 230 this.widgetConfig, 231 (error, result) => { 232 if (error) { 233 console.error('FileUpload: Upload error', error) 234 if (error.status === 'Upload preset not found') { 235 void AppOverlay.alert({ 236 title: "Upload preset missing", 237 message: "Upload preset not found. Please configure a valid Cloudinary upload preset." 238 }) 239 } 240 this.setUploading(false) 241 return 242 } 243 244 if (result) { 245 if (result.event === "queues-start") { 246 // Upload started 247 this.setUploading(true) 248 } else if (result.event === "success") { 249 // Upload completed 250 this.createCmsAttachment(result.info) 251 this.setUploading(false) 252 } else if (result.event === "close") {
253 // Widget closed 254 this.setUploading(false) 255 } 256 } 257 } 258 ) 259 } 260 261 async uploadFileDirectly(file) { 262 const cloudName = window.CLOUDINARY_CLOUD_NAME 263 const uploadPreset = this.widgetConfig.uploadPreset 264 265 if (!cloudName || !uploadPreset) { 266 console.error('FileUpload: Cloudinary not configured') 267 return 268 } 269 270 // Show loading indicator 271 this.setUploading(true) 272 273 try { 274 // Create FormData for direct upload to Cloudinary 275 const formData = new FormData() 276 formData.append('file', file) 277 formData.append('upload_preset', uploadPreset) 278 279 // Add folder if configured 280 if (this.widgetConfig.folder) { 281 formData.append('folder', this.widgetConfig.folder) 282 } 283 284 // Upload directly to Cloudinary API 285 const response = await fetch(`https://api.cloudinary.com/v1_1/${cloudName}/upload`, { 286 method: 'POST', 287 body: formData 288 }) 289 290 if (response.ok) { 291 const result = await response.json() 292 // Create CMS attachment with Cloudinary response 293 await this.createCmsAttachment(result) 294 } else { 295 const error = await response.json() 296 console.error('FileUpload: Direct upload failed', error) 297 await AppOverlay.alert({ 298 title: "Upload failed", 299 message: `Upload failed: ${error.error?.message || 'Unknown error'}` 300 }) 301 } 302 } catch (error) { 303 console.error('FileUpload: Error uploading file directly', error) 304 await AppOverlay.alert({ 305 title: "Upload failed", 306 message: "Upload failed. Please try again." 307 }) 308 } finally { 309 // Hide loading indicator 310 this.setUploading(false) 311 } 312 } 313 314 setUploading(isUploading) { 315 if (!this.dropzone) return 316 317 if (isUploading) { 318 this.uploadingCount++ 319 this.dropzone.classList.add('uploading') 320 this.dropzone.style.pointerEvents = 'none' 321 } else { 322 this.uploadingCount = Math.max(0, this.uploadingCount - 1) 323 if (this.uploadingCount === 0) { 324 this.dropzone.classList.remove('uploading') 325 this.dropzone.style.pointerEvents = '' 326 } 327 } 328 } 329 330 async createCmsAttachment(cloudinaryResponse) { 331 const postData = { 332 entity_name: this.entityValue, 333 inside_id: this.insideIdValue, 334 attachments_group: this.attributeValue, 335 response: JSON.stringify(cloudinaryResponse) 336 } 337 338 try { 339 const response = await fetch(this.createUrlValue, { 340 method: 'POST', 341 headers: { 342 'Content-Type': 'application/json', 343 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content 344 }, 345 body: JSON.stringify(postData) 346 }) 347 348 if (response.ok) { 349 const data = await response.json() 350 this.createPreview(data.data) 351 } else { 352 console.error('FileUpload: Failed to create attachment', response.statusText) 353 } 354 } catch (error) { 355 console.error('FileUpload: Error creating attachment', error) 356 } 357 } 358 359 async loadCmsAttachment() { 360 try { 361 const response = await fetch(this.indexUrlValue, { 362 method: 'GET', 363 headers: { 364 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content 365 } 366 }) 367 368 if (response.ok) { 369 const data = await response.json() 370 if (data.data && Array.isArray(data.data)) { 371 data.data.forEach(attachment => { 372 this.createPreview(attachment) 373 }) 374 } 375 } else { 376 console.error('FileUpload: Failed to load attachments', response.status, response.statusText) 377 } 378 } catch (error) { 379 console.error('FileUpload: Error loading attachments', error) 380 } 381 } 382 383 createPreview(data) { 384 const preview = new Preview(data, this.deleteUrlValue, { 385 previewWidth: this.config.previewW, 386 previewHeight: this.config.previewH, 387 callback: () => this.updateInput() 388 }) 389 390 const thumbnailsContainer = this.hasThumbnailsTarget ? this.thumbnailsTarget : this.element.querySelector('.thumbnails') 391 392 if (thumbnailsContainer) { 393 thumbnailsContainer.appendChild(preview.render()) 394 this.updateInput() 395 396 // Dispatch custom event for header-attachment controller 397 const event = new CustomEvent('file-upload:preview-created', { 398 detail: { 399 data: data, 400 attribute: this.attributeValue 401 }, 402 bubbles: true 403 }) 404 this.element.dispatchEvent(event) 405 } else { 406 console.error('FileUpload: Thumbnails container not found') 407 } 408 } 409 410 updateInput() { 411 if (!this.hasThumbnailsTarget) return 412 413 const previews = this.thumbnailsTarget.querySelectorAll('.upload-preview') 414 const dropzone = this.element.querySelector('.dropzone') 415 416 if (!this.config.multiple && previews.length >= 1) { 417 if (dropzone) dropzone.style.display = 'none' 418 } else { 419 if (dropzone) dropzone.style.display = 'block' 420 } 421 } 422} 423 424// Preview class for rendering uploaded files 425class Preview { 426 constructor(data, deleteUrl, options = {}) { 427 this.deleteUrl = deleteUrl + data.id 428 this.callback = options.callback || (() => {}) 429 this.resourceId = data.public_id 430 this.resourceType = data.resource_type || 'image' 431 this.deleteToken = data.delete_token 432 this.format = data.format 433 this.version = data.version // May be undefined for existing attachments 434 this.previewWidth = options.previewWidth || 150 435 this.previewHeight = options.previewHeight || 150 436 } 437 438 render() { 439 const preview = document.createElement('div') 440 preview.className = 'upload-preview' 441 preview.style.cursor = 'pointer' 442 preview.dataset.originalUrl = this.originalUrl() 443 444 if (this.resourceType === 'video') { 445 preview.innerHTML = this.renderVideo() 446 } else { 447 preview.innerHTML = this.renderImage() 448 } 449 450 preview.appendChild(this.createDeleteButton(preview)) 451 return preview 452 } 453 454 originalUrl() { 455 // Construct URL manually using configured cloud name 456 const cloudName = window.CLOUDINARY_CLOUD_NAME || 'insideguidance' 457 if (!cloudName || cloudName === 'undefined') { 458 console.error('Preview: Cloudinary cloud name not configured') 459 return '' 460 } 461 462 // Build URL parts 463 let url = `https://res.cloudinary.com/${cloudName}/${this.resourceType}/upload` 464 465 // Add version if available (format: v123456, not v_123456) 466 if (this.version) { 467 url += `/v${this.version}` 468 } 469 470 // Add public_id and format 471 // public_id may already include folder path (e.g., "Inside/pryxgqxfhk4hx0snwlse") 472 // Don't append format if public_id already ends with it 473 const publicId = this.resourceId 474 const format = this.format || 'jpg' 475 if (publicId.endsWith(`.${format}`)) { 476 url += `/${publicId}` 477 } else { 478 url += `/${publicId}.${format}` 479 } 480 481 return url 482 } 483 484 renderImage() { 485 const format = this.format === 'tiff' ? 'jpg' : (this.format || 'jpg') 486 const cloudName = window.CLOUDINARY_CLOUD_NAME || 'insideguidance' 487 488 if (!cloudName || cloudName === 'undefined') { 489 console.error('Preview: Cloudinary cloud name not configured') 490 return '<div class="preview-error">Cloudinary not configured</div>' 491 } 492 493 // Build transformation URL 494 // Format: https://res.cloudinary.com/{cloud_name}/{resource_type}/upload/{transformations}/{version}/{public_id}.{format} 495 // Transformations: c_pad,w_150,h_150 496 // Version: v123456 (no underscore) 497 let url = `https://res.cloudinary.com/${cloudName}/image/upload/c_pad,w_${this.previewWidth},h_${this.previewHeight}` 498 499 // Add version if available (format: v123456, not v_123456) 500 if (this.version) { 501 url += `/v${this.version}` 502 } 503 504 // Add public_id and format 505 const publicId = this.resourceId 506 if (publicId.endsWith(`.${format}`)) { 507 url += `/${publicId}` 508 } else { 509 url += `/${publicId}.${format}` 510 } 511 512 return `<img src="${url}" class="preview-img" alt="Preview" width="${this.previewWidth}" height="${this.previewHeight}" style="width: ${this.previewWidth}px; height: ${this.previewHeight}px; object-fit: contain;" />` 513 } 514 515 renderVideo() { 516 const cloudName = window.CLOUDINARY_CLOUD_NAME || 'insideguidance' 517 518 if (!cloudName || cloudName === 'undefined') { 519 console.error('Preview: Cloudinary cloud name not configured') 520 return '<div class="preview-error">Cloudinary not configured</div>' 521 } 522 523 // Build transformation URL 524 let url = `https://res.cloudinary.com/${cloudName}/video/upload/c_pad,w_${this.previewWidth},h_${this.previewHeight}` 525 526 // Add version if available (format: v123456, not v_123456) 527 if (this.version) { 528 url += `/v${this.version}` 529 } 530 531 // Add public_id and format 532 const publicId = this.resourceId 533 const format = this.format || 'mp4' 534 if (publicId.endsWith(`.${format}`)) { 535 url += `/${publicId}` 536 } else { 537 url += `/${publicId}.${format}` 538 } 539 540 return `<video src="${url}" controls class="preview-video"></video>` 541 } 542 543 createDeleteButton(preview) { 544 const deleteButton = document.createElement('button') 545 deleteButton.className = 'btn btn-sm btn-danger preview-delete' 546 deleteButton.style.cursor = 'pointer' 547 deleteButton.type = 'button' 548 deleteButton.textContent = 'Delete' 549 550 deleteButton.addEventListener('click', async (evt) => { 551 evt.preventDefault() 552 evt.stopPropagation() 553
554 try { 555 const response = await fetch(this.deleteUrl, { 556 method: 'DELETE', 557 headers: { 558 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content 559 } 560 }) 561 562 if (response.ok) { 563 preview.remove() 564 this.callback() 565 } else { 566 console.error('FileUpload: Failed to delete attachment', response.statusText) 567 } 568 } catch (error) { 569 console.error('FileUpload: Error deleting attachment', error) 570 } 571 }) 572 573 return deleteButton 574 } 575} 576;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.