PageSourceSearch

https://insideguidance.com/assets/controllers/file_upload_controll…2d7721f6bf2cdd3765dff70f4ddc11e9d0ecd0af.js

js insideguidance.com collected 2026-09-26 01:08:58 UTC 18,044 bytes, 576 lines download raw bytes

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.