PageSourceSearch

https://topfood.app/assets/controllers/qr_downloads_controller-750344c5.js

js topfood.app collected 2026-09-30 22:17:44 UTC 17,069 bytes, 435 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// The download dialog on the QR screen (docs/QR_DESIGNER.md → "Downloads").
4// Lives on the design form next to qr-designer and form-dirty.
5//
6// What it guards against: a download while the form has unsaved changes hands
7// over the previously saved design. So the openers go away (below lg) or are
8// disabled (from lg) as soon as anything changes, and inside the dialog an edit
9// to the printed text swaps the download button for Save. A save made from the
10// dialog posts `reopen_download` and the page comes back with the dialog open
11// on the same choice.
12//
13// The download itself is a plain link the browser handles: Safari on iOS only
14// honours a download started inside the tap that asked for it, and fetching the
15// bytes into a blob first spends that permission, so the file never arrives.
16// To still say "Preparing the file…" while a 35-cell PDF is composed, the link
17// is stamped with a one-off token and the response comes back with that token
18// in a cookie — which is the signal that the bytes have left the server.
19export default class extends Controller {
20  static targets = [
21    "dialog", "title", "back", "menu", "tab", "workspace", "scroller", "footer", "options", "radio", "preview",
22    "textBlock", "printHeadline", "headlineField", "headline", "gridCode", "codeSide", "noRoom",
23    "download", "downloadLabel", "downloadIcon", "spinner", "share",
24    "save", "saveLabel", "saveSpinner", "unsavedNote", "error",
25    "reopenField", "desktopOpener", "mobileOpener", "openerNote"
26  ]
27
28  static values = {
29    reopen: String,
30    failureUrl: String,
31    unsaved: Boolean,
32    preparingLabel: String,
33    downloadedLabel: String,
34    savingLabel: String,
35    saveChangesLabel: String,
36    downloadFailedLabel: String,
37    saveFailedLabel: String
38  }
39
40  static STORAGE_KEY = "qr_downloads.choice"
41  static COOKIE = "qr_download"
42  static DESKTOP = "(min-width: 1024px)"
43  static DONE_MS = 2000
44  static POLL_MS = 250
45  // A PDF is composed in seconds; a request that has not answered in this long
46  // is not going to (a proxy timeout or a dropped navigation never reaches the
47  // app, so silence is all there is to read). Long enough not to cry wolf over
48  // a slow phone, short enough that the owner is not stuck watching a spinner.
49  static GIVE_UP_MS = 20_000
50  static FAILED_SUFFIX = ".failed"
51
52  connect() {
53    // A save the server rejected re-renders this page with the posted choices
54    // in the form but nothing stored: the downloads are as stale as if the
55    // form were dirty.
56    this.dirty = this.unsavedValue
57    this.group = null
58    this.kind = null
59    this.choices = this.readChoices()
60    this.desktop = window.matchMedia(this.constructor.DESKTOP)
61    this.boundLayoutChanged = () => this.layoutChanged()
62    this.desktop.addEventListener("change", this.boundLayoutChanged)
63    this.syncOpeners()
64
65    if (this.reopenValue) {
66      const radio = this.radioFor(this.reopenValue)
67      if (radio) {
68        this.choices[radio.dataset.group] = radio.value
69        this.showModal()
70        this.showGroup(radio.dataset.group)
71      }
72      this.forgetReopen()
73    }
74  }
75
76  disconnect() {
77    this.desktop.removeEventListener("change", this.boundLayoutChanged)
78    clearTimeout(this.doneTimer)
79    clearInterval(this.pollTimer)
80  }
81
82  // ── Opening and navigating ───────────────────────────────────────────────
83
84  open() {
85    if (this.dirty) return
86    // Read again on every open: another tab may have downloaded since.
87    this.choices = this.readChoices()
88    this.showModal()
89    // A phone starts from the three groups; a desktop dialog has them as tabs
90    // and opens on the one used last.
91    if (this.desktop.matches) this.showGroup(this.choices.last || this.firstGroup())
92    else this.showMenu()
93  }
94
95  close() {
96    if (this.dialogTarget.open) this.dialogTarget.close()
97  }
98
99  closeOnBackdrop(event) {
100    if (event.target === this.dialogTarget) this.close()
101  }
102
103  showModal() {
104    if (this.dialogTarget.open) return
105    if (typeof this.dialogTarget.showModal === "function") this.dialogTarget.showModal()
106    else this.dialogTarget.setAttribute("open", "")
107  }
108
109  showMenu() {
110    this.group = null
111    this.menuTarget.hidden = false
112    this.workspaceTarget.hidden = true
113    this.footerTarget.hidden = true
114    this.backTarget.hidden = true
115    this.titleTarget.textContent = this.titleTarget.dataset.defaultTitle
116  }
117
118  // From a menu row or a tab (params), or directly with a group name.
119  showGroup(eventOrGroup) {
120    const group = typeof eventOrGroup === "string" ? eventOrGroup : eventOrGroup.params.group
121    const fieldset = this.optionsTargets.find((el) => el.dataset.group === group)
122    if (!fieldset) return
123
124    this.group = group
125    this.choices.last = group
126    this.menuTarget.hidden = true
127    this.workspaceTarget.hidden = false
128    this.scrollerTarget.scrollTop = 0
129    this.footerTarget.hidden = false
130    this.backTarget.hidden = false
131    this.optionsTargets.forEach((el) => { el.hidden = el !== fieldset })
132    this.tabTargets.forEach((el) => el.setAttribute("aria-selected", String(el.dataset.group === group)))
133    this.textBlockTarget.hidden = !this.radiosIn(group).some((radio) => radio.dataset.headlineFits !== undefined)
134    this.renderTitle()
135
136    const radio = this.radioFor(this.choices[group]) || this.radiosIn(group)[0]
137    radio.checked = true
138    this.select(radio)
139  }
140
141  // Widening a phone-sized window with the group list showing would leave the
142  // dialog empty: from lg the list is hidden in favour of the tabs.
143  layoutChanged() {
144    if (!this.dialogTarget.open) return
145    if (this.desktop.matches && !this.group) this.showGroup(this.choices.last || this.firstGroup())
146    else this.renderTitle()
147  }
148
149  renderTitle() {
150    const fieldset = this.optionsTargets.find((el) => el.dataset.group === this.group)
151    this.titleTarget.textContent = !this.desktop.matches && fieldset
152      ? fieldset.dataset.title
153      : this.titleTarget.dataset.defaultTitle
154  }
155
156  // ── Choosing a file ──────────────────────────────────────────────────────
157
158  choose(event) {
159    this.select(event.target)
160  }
161
162  // The option radios are not design fields. Their input event is stopped here
163  // so neither form-dirty nor qr-designer mistakes a choice for an edit.
164  swallow() {}
165
166  select(radio) {
167    this.kind = radio
168    this.choices[radio.dataset.group] = radio.value
169    this.writeChoices()
170    this.previewTargets.forEach((el) => { el.hidden = el.dataset.download !== radio.value })
171    this.errorTarget.classList.add("hidden")
172    this.downloadTarget.href = radio.dataset.url
173    this.downloadTarget.setAttribute("download", radio.dataset.filename)
174    this.resetDownloadButton()
175    this.renderText()
176    this.renderFooter()
177    this.prepareShare()
178  }
179
180  // ── Printed text ─────────────────────────────────────────────────────────
181
182  // Any change anywhere in the form (declared on the form element). An edit
183  // inside the dialog lands here too, which is what turns Download into Save.
184  formChanged() {
185    this.dirty = true
186    this.syncOpeners()
187    if (!this.dialogTarget.open) return
188    this.renderText()
189    this.renderFooter()
190  }
191
192  renderText() {
193    if (!this.hasHeadlineFieldTarget) return
194    const wanted = this.printHeadlineTarget.checked
195    const text = this.headlineFieldTarget.value.trim() || this.headlineFieldTarget.placeholder
196
197    this.headlineTargets.forEach((el) => {
198      el.textContent = text
199      el.hidden = !wanted
200    })
201    // The headline's line comes out of the code's height in a grid cell, so
202    // both the drawing and the stated size move with the checkbox.
203    this.gridCodeTargets.forEach((img) => {
204      img.style.width = wanted ? img.dataset.withHeadline : img.dataset.withoutHeadline
205    })
206    this.codeSideTargets.forEach((el) => { el.hidden = (el.dataset.headline === "true") !== wanted })
207
208    const fits = this.kind?.dataset.headlineFits
209    this.noRoomTarget.hidden = !(wanted && fits === "false")
210  }
211
212  // ── Footer ───────────────────────────────────────────────────────────────
213
214  renderFooter() {
215    const show = (el, visible) => el.classList.toggle("hidden", !visible)
216    show(this.saveTarget, this.dirty)
217    show(this.unsavedNoteTarget, this.dirty)
218    show(this.downloadTarget, !this.dirty)
219    show(this.shareTarget, !this.dirty && Boolean(this.shareFile) && this.kind?.value === "png")
220  }
221
222  syncOpeners() {
223    this.mobileOpenerTargets.forEach((el) => { el.hidden = this.dirty })
224    this.desktopOpenerTargets.forEach((el) => { el.disabled = this.dirty })
225    this.openerNoteTargets.forEach((el) => { el.hidden = !this.dirty })
226  }
227
228  // Saving is not instant and the owner must be able to see that. The server
229  // draws the code, reads it back and stores it before answering — a couple of
230  // seconds (docs/QR_DESIGNER.md → "Rendering on the server"). Without this the
231  // button simply sat there, which reads as a screen that has stopped
232  // responding.
233  save() {
234    if (this.saving) return
235    this.saving = true
236    this.errorTarget.classList.add("hidden")
237    this.setSaveButton(true)
238    this.reopenFieldTarget.value = this.kind ? this.kind.value : ""
239    // Only the choices go; the server draws and reads back the code, which is
240    // most of what this save waits for now (docs/QR_DESIGNER.md → "Rendering
241    // on the server").
242    this.element.requestSubmit()
243  }
244
245  // The submit left and came back as a failure — a 5xx from the proxy, or a
246  // connection that dropped mid-save. A save the server refused (a render it
247  // could not read back) re-renders the page with its own alert instead.
248  // Turbo renders its own body for a response it can render, so this is the
249  // case where the page stays put and nothing else would say a word.
250  submitEnded(event) {
251    if (event.detail?.success) return
252    this.failSave()
253  }
254
255  failSave() {
256    this.saving = false
257    this.setSaveButton(false)
258    if (!this.dialogTarget.open) return
259    this.errorTarget.textContent = this.saveFailedLabelValue
260    this.errorTarget.classList.remove("hidden")
261  }
262
263  setSaveButton(saving) {
264    this.saveTarget.setAttribute("aria-disabled", String(saving))
265    this.saveSpinnerTarget.hidden = !saving
266    this.saveLabelTarget.textContent = saving ? this.savingLabelValue : this.saveChangesLabelValue
267  }
268
269  // ── Downloading ──────────────────────────────────────────────────────────
270
271  // Runs inside the click, before the browser follows the link: everything
272  // here is synchronous, so the tap still counts as the download's gesture.
273  download(event) {
274    if (this.busy || !this.kind) {
275      event.preventDefault()
276      return
277    }
278    const token = `${Date.now()}${Math.random().toString(36).slice(2, 10)}`
279    this.downloadTarget.href = `${this.kind.dataset.url}?token=${token}`
280    this.errorTarget.classList.add("hidden")
281    this.setDownloadButton("preparing")
282    this.waitFor(token)
283  }
284
285  // The response sets a cookie carrying the token back, which is the only
286  // signal a page gets that a download it did not fetch has started.
287  waitFor(token) {
288    this.busy = true
289    const startedAt = Date.now()
290    clearInterval(this.pollTimer)
291    this.pollTimer = setInterval(() => {
292      const cookie = this.cookieToken()
293      if (cookie === token) {
294        this.forgetCookie()
295        this.finishDownload("done")
296      } else if (cookie === `${token}${this.constructor.FAILED_SUFFIX}`) {
297        // The server answered that it could not make the file.
298        this.forgetCookie()
299        this.failDownload()
300      } else if (Date.now() - startedAt > this.constructor.GIVE_UP_MS) {
301        this.reportGiveUp()
302        this.failDownload()
303      }
304    }, this.constructor.POLL_MS)
305  }
306
307  // Says so and gives the button back: `busy` is cleared, so the owner can tap
308  // Download again without reloading the page.
309  failDownload() {
310    this.errorTarget.textContent = this.downloadFailedLabelValue
311    this.errorTarget.classList.remove("hidden")
312    this.finishDownload("idle")
313  }
314
315  // A request that never reached the app leaves no server-side trace, so the
316  // page reports it. Best effort: it must never get in the owner's way.
317  reportGiveUp() {
318    const csrf = document.querySelector("meta[name='csrf-token']")?.content
319    fetch(this.failureUrlValue, {
320      method: "POST",
321      credentials: "same-origin",
322      keepalive: true,
323      headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": csrf || "" },
324      body: new URLSearchParams({ kind: this.kind.value })
325    }).catch(() => {})
326  }
327
328  finishDownload(state) {
329    clearInterval(this.pollTimer)
330    this.busy = false
331    this.setDownloadButton(state)
332  }
333
334  cookieToken() {
335    const match = document.cookie.match(new RegExp(`(?:^|; )${this.constructor.COOKIE}=([^;]*)`))
336    return match ? decodeURIComponent(match[1]) : null
337  }
338
339  forgetCookie() {
340    document.cookie = `${this.constructor.COOKIE}=; Max-Age=0; path=/`
341  }
342
343  setDownloadButton(state) {
344    clearTimeout(this.doneTimer)
345    const preparing = state === "preparing"
346    this.downloadTarget.setAttribute("aria-disabled", String(preparing))
347    this.spinnerTarget.hidden = !preparing
348    this.downloadIconTarget.hidden = preparing
349    this.downloadLabelTarget.textContent = preparing
350      ? this.preparingLabelValue
351      : state === "done" ? this.downloadedLabelValue : this.kind.dataset.label
352    if (state === "done") this.doneTimer = setTimeout(() => this.resetDownloadButton(), this.constructor.DONE_MS)
353  }
354
355  resetDownloadButton() {
356    if (this.kind && !this.busy) this.setDownloadButton("idle")
357  }
358
359  // ── Sharing the PNG ──────────────────────────────────────────────────────
360
361  // navigator.share must run inside the tap, so the file is fetched ahead of
362  // it — only on a browser that can share files, and only once.
363  async prepareShare() {
364    if (this.kind?.value !== "png" || this.shareFile || this.sharing) return
365    if (typeof navigator.share !== "function" || typeof navigator.canShare !== "function") return
366    this.sharing = true
367    try {
368      const response = await fetch(this.kind.dataset.url, { credentials: "same-origin" })
369      if (!response.ok || response.redirected) return
370      const blob = await response.blob()
371      const file = new File([blob], this.filenameFrom(response), { type: blob.type || "image/png" })
372      if (navigator.canShare({ files: [file] })) {
373        this.shareFile = file
374        this.renderFooter()
375      }
376    } catch (error) {
377      console.warn("QR share unavailable", error)
378    } finally {
379      this.sharing = false
380    }
381  }
382
383  // No await before this call: on iOS the tap's permission is spent by the
384  // first one, and share() would then be refused.
385  share() {
386    if (!this.shareFile) return
387    navigator.share({ files: [this.shareFile] }).catch((error) => {
388      if (error.name === "AbortError") return
389      console.warn("QR share failed", error)
390      // The file is still one tap away through the download link.
391      this.shareTarget.classList.add("hidden")
392    })
393  }
394
395  // ── Helpers ──────────────────────────────────────────────────────────────
396
397  radioFor(key) {
398    return key ? this.radioTargets.find((radio) => radio.value === key) : null
399  }
400
401  radiosIn(group) {
402    return this.radioTargets.filter((radio) => radio.dataset.group === group)
403  }
404
405  firstGroup() {
406    return this.optionsTargets[0].dataset.group
407  }
408
409  // The last choice in each group, and the group used last. Storage c
409an be
410  // missing or throw (private windows, blocked site data); the dialog works
411  // the same without it.
412  readChoices() {
413    try {
414      const stored = JSON.parse(window.localStorage.getItem(this.constructor.STORAGE_KEY) || "{}")
415      return stored && typeof stored === "object" ? stored : {}
416    } catch {
417      return {}
418    }
419  }
420
421  writeChoices() {
422    try {
423      window.localStorage.setItem(this.constructor.STORAGE_KEY, JSON.stringify(this.choices))
424    } catch {
425      // Remembering is a convenience.
426    }
427  }
428
429  // Reloading the page must not open the dialog again.
430  forgetReopen() {
431    const url = new URL(window.location.href)
432    url.searchParams.delete("download")
433    window.history.replaceState(window.history.state, "", url)
434  }
435}

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.