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.