PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/inline_image_upload_controller-acd2037b.js

js georgetowncoc.com collected 2026-09-25 04:04:57 UTC 8,890 bytes, 235 lines download raw bytes

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.