PageSourceSearch

https://cdn2.futurefund.com/assets/honey/block_builder/controllers/asset_picker_controller-bc12bd98.js

js futurefund.com collected 2026-09-24 11:52:48 UTC 11,027 bytes, 355 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Asset picker modal for selecting image assets
4// Shows a searchable grid of image thumbnails
5// Works with honey-ui modal helper (HTML5 dialog)
6export default class extends Controller {
7  static targets = ["search", "grid", "loading", "selectedThumbnail", "selectedName", "uploadSection", "uploadForm", "uploadToggle", "uploadError", "fileInput", "uploadSubmit"]
8  static values = {
9    assetsUrl: { type: String, default: "/admin/assets.json" },
10    uploadUrl: { type: String, default: "/admin/assets.json" }
11  }
12
13  connect() {
14    this.assets = []
15    this.filteredAssets = []
16    this.selectedAsset = null
17    this.callback = null
18    this.loadAssets()
19  }
20
21  async loadAssets() {
22    try {
23      const response = await fetch(this.assetsUrlValue)
24      const data = await response.json()
25      this.assets = data.assets || []
26      this.filteredAssets = this.assets
27      // Hide loading state once loaded
28      if (this.hasLoadingTarget) {
29        this.loadingTarget.classList.add("hidden")
30      }
31    } catch (error) {
32      console.error("Failed to load assets:", error)
33      this.assets = []
34      if (this.hasLoadingTarget) {
35        this.loadingTarget.innerHTML = `<p class="text-red-400">Failed to load assets</p>`
36      }
37    }
38  }
39
40  // Called externally to open the picker
41  open(currentValue, callback) {
42    this.callback = callback
43    this.selectedAsset = currentValue ? this.assets.find(a => String(a.id) === String(currentValue)) : null
44
45    // Reset search
46    if (this.hasSearchTarget) {
47      this.searchTarget.value = ""
48    }
49
50    // Reset filtered assets
51    this.filteredAssets = this.assets
52
53    // Update selected preview
54    this.updateSelectedPreview()
55
56    this.renderGrid()
57
58    // Reset upload form state
59    if (this.hasUploadSectionTarget) {
60      this.uploadSectionTarget.classList.add("hidden")
61    }
62    if (this.hasUploadToggleTarget) {
63      this.uploadToggleTarget.innerHTML = `<i class="fa-solid fa-upload mr-1"></i> Upload New`
64    }
65    this.resetUploadForm()
66
67    // Show the dialog (the controller is attached to the dialog element)
68    this.element.showModal()
69
70    // Focus search input
71    requestAnimationFrame(() => {
72      if (this.hasSearchTarget) {
73        this.searchTarget.focus()
74      }
75    })
76  }
77
78  close() {
79    this.element.close()
80    this.callback = null
81  }
82
83  search(event) {
84    const query = event.target.value.toLowerCase().trim()
85    this.filterAssets(query)
86    this.renderGrid()
87  }
88
89  filterAssets(query) {
90    if (!query) {
91      this.filteredAssets = this.assets
92      return
93    }
94
95    // Score assets by match quality on name
96    const scored = this.assets.map(asset => {
97      const name = (asset.name || "").toLowerCase()
98      const altText = (asset.alt_text || "").toLowerCase()
99      let score = 0
100
101      if (name === query || altText === query) score = 100
102      else if (name.startsWith(query) || altText.startsWith(query)) score = 80
103      else if (name.includes(query) || altText.includes(query)) score = 50
104
105      return { asset, score }
106    })
107
108    this.filteredAssets = scored
109      .filter(s => s.score > 0)
110      .sort((a, b) => b.score - a.score)
111      .map(s => s.asset)
112  }
113
114  renderGrid() {
115    if (!this.hasGridTarget) return
116
117    this.gridTarget.innerHTML = ""
118
119    if (this.filteredAssets.length === 0) {
120      const noResults = document.createElement("div")
121      noResults.className = "col-span-full text-center text-slate-300 py-8"
122      noResults.textContent = "No assets found. Try a different search term."
123      this.gridTarget.appendChild(noResults)
124      return
125    }
126
127    this.filteredAssets.forEach(asset => {
128      const button = document.createElement("button")
129      button.type = "button"
130      button.className = this.getAssetButtonClasses(this.selectedAsset && this.selectedAsset.id === asset.id)
131      button.dataset.action = "click->honey-block-builder--asset-picker#selectAsset"
132      button.dataset.assetId = asset.id
133      button.title = asset.name
134
135      // Thumbnail container
136      const thumbWrapper = document.createElement("div")
137      thumbWrapper.className = "w-20 h-20 flex items-center justify-center overflow-hidden rounded-xs bg-warm-200"
138
139      if (asset.thumbnail_url) {
140        const img = document.createElement("img")
141        img.src = asset.thumbnail_url
142        img.alt = asset.alt_text || asset.name
143        img.className = "w-full h-full object-cover"
144        img.loading = "lazy"
145        thumbWrapper.appendChild(img)
146      } else {
147        const placeholder = document.createElement("i")
148        placeholder.className = "fa-regular fa-image text-xl text-slate-300"
149        thumbWrapper.appendChild(placeholder)
150      }
151
152      // Asset name
153      const label = document.createElement("span")
154      label.className = "text-xs text-slate-300 truncate w-full px-1 mt-1"
155      label.textContent = this.truncateName(asset.name)
156
157      button.appendChild(thumbWrapper)
158      button.appendChild(label)
159      this.gridTarget.appendChild(button)
160    })
161  }
162
163  getAssetButtonClasses(isSelected) {
164    const base = "flex flex-col items-center justify-center p-2 rounded-xs border transition-all hover:border-blue-200 hover:bg-white"
165    if (isSelected) {
166      return `${base} border-blue-200 bg-white`
167    }
168    return `${base} border-warm-300 bg-warm-200`
169  }
170
171  truncateName(name) {
172    if (!name) return ""
173    if (name.length <= 12) return name
174    return name.substring(0, 11) + "..."
175  }
176
177  selectAsset(event) {
178    const assetId = event.currentTarget.dataset.assetId
179    this.selectedAsset = this.assets.find(a => String(a.id) === String(assetId))
180    this.updateSelectedPreview()
181    this.renderGrid()
182  }
183
184  updateSelectedPreview() {
185    if (this.hasSelectedThumbnailTarget) {
186      if (this.selectedAsset && this.selectedAsset.thumbnail_url) {
187        this.selectedThumbnailTarget.innerHTML = `<img src="${this.selectedAsset.thumbnail_url}" alt="${this.selectedAsset.alt_text || this.selectedAsset.name}" class="w-full h-full object-cover">`
188      } else {
189        this.selectedThumbnailTarget.innerHTML = `<i class="fa-regular fa-image text-2xl text-slate-300"></i>`
190      }
191    }
192    if (this.hasSelectedNameTarget) {
193      this.selectedNameTarget.textContent = this.selectedAsset ? this.selectedAsset.name : "No asset selected"
194    }
195  }
196
197  confirm() {
198    if (this.selectedAsset && this.callback) {
199      // Pass both the ID and the full asset object so the caller can update its asset options
200      this.callback(this.selectedAsset.id, this.selectedAsset)
201    }
202    this.close()
203  }
204
205  // Handle keyboard events
206  handleKeydown(event) {
207    if (event.key === "Escape") {
208      this.close()
209    } else if (event.key === "Enter" && this.selectedAsset) {
210      event.preventDefault()
211      this.confirm()
212    }
213  }
214
215  // Toggle upload form visibility
216  toggleUploadForm() {
217    if (this.hasUploadSectionTarget) {
218      const isHidden = this.uploadSectionTarget.classList.contains("hidden")
219      this.uploadSectionTarget.classList.toggle("hidden")
220
221      // Update toggle button text
222      if (this.hasUploadToggleTarget) {
223        this.uploadToggleTarget.innerHTML = isHidden
224          ? "Cancel Upload"
225          : `<i class="fa-solid fa-upload mr-1"></i> Upload New`
226      }
227
228      // Clear form and errors when hiding
229      if (!isHidden) {
230        this.resetUploadForm()
231      }
232    }
233  }
234
235  // Cancel upload and hide form
236  cancelUpload() {
237    this.resetUploadForm()
238    if (this.hasUploadSectionTarget) {
239      this.uploadSectionTarget.classList.add("hidden")
240    }
241    if (this.hasUploadToggleTarget) {
242      this.uploadToggleTarget.innerHTML = `<i class="fa-solid fa-upload mr-1"></i> Upload New`
243    }
244  }
245
246  // Reset upload form state
247  resetUploadForm() {
248    if (this.hasUploadFormTarget) {
249      this.uploadFormTarget.reset()
250    }
251    if (this.hasUploadSubmitTarget) {
252      this.uploadSubmitTarget.disabled = true
253    }
254    this.hideUploadError()
255  }
256
257  // Show upload error message
258  showUploadError(message) {
259    if (this.hasUploadErrorTarget) {
260      this.uploadErrorTarget.textContent = message
261      this.uploadErrorTarget.classList.remove("hidden")
262    }
263  }
264
265  // Hide upload error message
266  hideUploadError() {
267    if (this.hasUploadErrorTarget) {
268      this.uploadErrorTarget.textContent = ""
269      this.uploadErrorTarget.classList.add("hidden")
270    }
271  }
272
273  // Handle file input change - enable/disable upload button
274  handleFileChange(event) {
275    if (this.hasUploadSubmitTarget) {
276      this.uploadSubmitTarget.disabled = !event.target.files.length
277    }
278  }
279
280  // Submit upload via fetch
281  async submitUpload(event) {
282    event.preventDefault()
283    this.hideUploadError()
284
285    if (!this.hasFileInputTarget || !this.fileInputTarget.files.length) {
286      this.showUploadError("Please select a file to upload")
287      return
288    }
289
290    // Build form data
291    const formData = new FormData()
292    formData.append("asset[file]", this.fileInputTarget.files[0])
293
294    // Get optional name and alt_text from form
295    const nameInput = this.uploadFormTarget.querySelector("[name='asset[name]']")
296    const altTextInput = this.uploadFormTarget.querySelector("[name='asset[alt_text]']")
297
298    if (nameInput && nameInput.value.trim()) {
299      formData.append("asset[name]", nameInput.value.trim())
300    }
301    if (altTextInput && altTextInput.value.trim()) {
302      formData.append("asset[alt_text]", altTextInput.value.trim())
303    }
304
305    // Disable submit button during upload
306    if (this.hasUploadSubmitTarget) {
307      this.uploadSubmitTarget.disabled = true
308      this.uploadSubmitTarget.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-1"></i> Uploading...`
309    }
310
311    try {
312      const response = await fetch(this.uploadUrlValue, {
313        method: "POST",
314        headers: {
315          "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content,
316          "Accept": "application/json"
317        },
318        body: formData
319      })
320
321      const data = await response.json()
322
323      if (response.ok && data.asset) {
324        // Add new asset to beginning of assets array
325        this.assets.unshift(data.asset)
326        this.filteredAssets = this.assets
327
328        // Select the new asset
329        this.selectedAsset = data.asset
330        this.updateSelectedPreview()
331
332        // Re-render grid and reset search
333        if (this.hasSearchTarget) {
334          this.searchTarget.value = ""
335        }
336        this.renderGrid()
337
338        // Hide upload form
339        this.cancelUpload()
340      } else {
341        const errorMsg = data.errors ? data.errors.join(", ") : "Upload failed"
342        this.showUploadError(errorMsg)
343      }
344    } catch (error) {
345      console.error("Upload failed:", error)
346      this.showUploadError("Upload failed. Please try again.")
347    } finally {
348      // Re-enable submit button
349      if (this.hasUploadSubmitTarget) {
350        this.uploadSubmitTarget.disabled = false
351        this.uploadSubmitTarget.innerHTML = `<i class="fa-solid fa-upload mr-1"></i> Upload`
352      }
353    }
354  }
355}

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.