1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static values = { 5 url: String 6 } 7 8 connect() { 9 this.refreshAllPreviews() 10 } 11 12 async upload(event) { 13 const fileInput = event.currentTarget 14 const file = fileInput?.files?.[0] 15 if (!file) return 16 17 const section = fileInput.closest("[data-section]") 18 if (!section) return 19 20 const fieldKey = fileInput.dataset.uploadFieldKey 21 if (!fieldKey) return 22 const scope = this.mediaScope(fileInput, section) 23 const urlField = this.urlField(scope, section, fieldKey, fileInput.dataset.uploadFieldPath) 24 const statusNode = scope.querySelector(`[data-upload-status-for="${CSS.escape(fieldKey)}"]`) 25 if (!urlField) return 26 27 this.setStatus(statusNode, "Uploadingâ¦", "pending") 28 fileInput.disabled = true 29 30 try { 31 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 32 const formData = new FormData() 33 formData.append("file", file) 34 35 const response = await fetch(this.urlValue, { 36 method: "POST", 37 headers: { 38 Accept: "application/json", 39 "X-CSRF-Token": csrfToken || "" 40 }, 41 body: formData 42 }) 43 44 const payload = await response.json().catch(() => ({})) 45 if (!response.ok) { 46 throw new Error(payload.error || "Upload failed.") 47 } 48 49 // A native progressive row may have moved while its upload was in flight. 50 // Resolve its current key and scope again before updating the held row. 51 const currentKey = fileInput.dataset.uploadFieldKey || fieldKey 52 const currentScope = this.mediaScope(fileInput, section) 53 const currentUrlField = this.urlField(currentScope, section, currentKey, fileInput.dataset.uploadFieldPath) || urlField 54 const currentStatusNode = currentScope.querySelector(`[data-upload-status-for="${CSS.escape(currentKey)}"]`) || statusNode 55 currentUrlField.value = payload.url || "" 56 currentUrlField.dispatchEvent(new Event("input", { bubbles: true })) 57 currentUrlField.dispatchEvent(new Event("change", { bubbles: true })) 58 this.updatePreviewForSection(section, currentKey, payload.url || "", currentScope) 59 this.setStatus(currentStatusNode, "Uploaded and linked.", "success") 60 } catch (error) { 61 this.setStatus(statusNode, error.message || "Upload failed.", "error") 62 } finally { 63 fileInput.disabled = false 64 fileInput.value = "" 65 } 66 } 67 68 refreshPreview(event) { 69 const input = event.currentTarget 70 const section = input.closest("[data-section]") 71 if (!section) return 72 73 const key = input.dataset.uploadPreviewKey 74 if (!key) return 75 this.updatePreviewForSection(section, key, input.value, this.mediaScope(input, section)) 76 } 77 78 remove(event) { 79 event.preventDefault() 80 81 const trigger = event.currentTarget 82 const section = trigger.closest("[data-section]") 83 if (!section) return 84 85 const key = trigger.dataset.uploadRemoveKey 86 if (!key) return 87 const scope = this.mediaScope(trigger, section) 88 const urlField = this.urlField(scope, section, key, trigger.dataset.uploadFieldPath) 89 if (!urlField) return 90 91 urlField.value = "" 92 urlField.dispatchEvent(new Event("input", { bubbles: true })) 93 urlField.dispatchEvent(new Event("change", { bubbles: true })) 94 95 scope.querySelectorAll(`[data-upload-field-key="${CSS.escape(key)}"]`).forEach((fileInput) => { 96 fileInput.value = "" 97 }) 98 99 this.updatePreviewForSection(section, key, "", scope) 100 const statusNode = scope.querySelector(`[data-upload-status-for="${CSS.escape(key)}"]`) 101 this.setStatus(statusNode, `${this.mediaLabelForSection(section, key, scope)} removed.`, "pending") 102 } 103 104 setStatus(node, message, state) { 105 if (!node) return 106 107 node.textContent = message 108 node.classList.remove("text-slate-500", "text-zinc-500", "text-emerald-700", "text-red-600") 109 110 if (state === "success") { 111 node.classList.add("text-emerald-700") 112 } else if (state === "error") { 113 node.classList.add("text-red-600") 114 } else { 115 node.classList.add("text-slate-500") 116 } 117 } 118 119 refreshAllPreviews() { 120 this.element.querySelectorAll("[data-upload-preview-key]").forEach((input) => { 121 const section = input.closest("[data-section]") 122 if (!section) return
123 const key = input.dataset.uploadPreviewKey 124 if (!key) return 125 this.updatePreviewForSection(section, key, input.value, this.mediaScope(input, section)) 126 }) 127 } 128 129 updatePreviewForSection(section, key, rawUrl, scope = section) { 130 const previewNode = scope.querySelector(`[data-upload-preview-for="${CSS.escape(key)}"]`) 131 if (!previewNode) return 132 133 const url = (rawUrl || "").trim() 134 previewNode.innerHTML = "" 135 previewNode.classList.remove("hidden") 136 137 if (!url) { 138 const empty = document.createElement("div") 139 empty.className = "rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500" 140 empty.textContent = `No ${this.mediaLabelForSection(section, key, scope).toLowerCase()} selected yet.` 141 previewNode.appendChild(empty) 142 return 143 } 144 145 if (!this.isSupportedMediaUrl(url)) { 146 const invalid = document.createElement("div") 147 invalid.className = "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" 148 invalid.textContent = "Use a valid URL starting with https://, http://, or /" 149 previewNode.appendChild(invalid) 150 return 151 } 152 153 const frame = document.createElement("a") 154 frame.href = url 155 frame.target = "_blank" 156 frame.rel = "noopener noreferrer" 157 frame.className = "block overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm" 158 159 const mediaKind = this.mediaKindForSection(section, key, scope) 160 161 if (mediaKind === "video") { 162 if (this.isDirectVideoUrl(url)) { 163 const video = document.createElement("video") 164 video.src = url 165 video.controls = true 166 video.preload = "metadata" 167 video.className = "h-36 w-full bg-slate-950 object-contain" 168 frame.appendChild(video) 169 } else { 170 const summary = document.createElement("div") 171 summary.className = "flex h-24 items-center justify-center bg-slate-950 px-3 text-center text-xs font-medium text-white" 172 summary.textContent = "Video URL linked" 173 frame.appendChild(summary) 174 } 175 } else if (mediaKind === "pdf") { 176 const summary = document.createElement("div") 177 summary.className = "flex h-24 items-center justify-center bg-slate-50 px-3 text-center text-xs font-medium text-slate-700" 178 summary.textContent = "PDF selected" 179 frame.appendChild(summary) 180 } else { 181 const image = document.createElement("img") 182 image.src = url 183 image.alt = "Image preview" 184 image.loading = "lazy" 185 image.className = "h-28 w-full object-cover" 186 image.addEventListener("error", () => { 187 previewNode.innerHTML = "" 188 const invalid = document.createElement("div") 189 invalid.className = "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" 190 invalid.textContent = "We could not load a preview for this URL. You can still save if the URL is correct." 191 previewNode.appendChild(invalid) 192 }) 193 frame.appendChild(image) 194 } 195 196 const meta = document.createElement("div") 197 meta.className = "border-t border-slate-200 px-3 py-2 text-xs text-slate-600" 198 meta.textContent = url.length > 120 ? `${url.slice(0, 117)}...` : url 199 frame.appendChild(meta) 200 201 previewNode.appendChild(frame) 202 } 203 204 mediaKindForSection(section, key, scope = section) { 205 const field = scope.querySelector(`[data-upload-preview-key="${CSS.escape(key)}"]`) 206 const preview = scope.querySelector(`[data-upload-preview-for="${CSS.escape(key)}"]`) 207 return field?.dataset.uploadPreviewKind || preview?.dataset.uploadPreviewKind || "image" 208 } 209 210 mediaLabelForSection(section, key, scope = section) { 211 const mediaKind = this.mediaKindForSection(section, key, scope) 212 if (mediaKind === "video") return "Video" 213 if (mediaKind === "pdf") return "PDF" 214 return "Image" 215 } 216 217 isSupportedMediaUrl(url) { 218 return /^https?:\/\//i.test(url) || /^\//.test(url) 219 } 220 221 isDirectVideoUrl(url) { 222 const path = url.split(/[?#]/, 1)[0] || "" 223 return /\.(mp4|webm|ogg)$/i.test(path) || /\/rails\/active_storage\/blobs\//.test(url) 224 } 225 226 mediaScope(element, section) { 227 return element.closest("[data-custom-repeater-item]") || section 228 } 229 230 urlField(scope, section, key, fieldPath = null) { 231 return scope.querySelector(`[data-upload-preview-key="${CSS.escape(key)}"]`) || 232 (fieldPath ? scope.querySelector(`[data-custom-repeater-item-field="${CSS.escape(fieldPath)}"]`) : null) || 233 section.querySelector(`[data-section-field="${CSS.escape(fieldPath || key)}"]`) 234 } 235}
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.