PageSourceSearch

https://beaky.com.br/assets/controllers/profile_photo_controller-8…0c827b0dc878351438683f6122255d09c2da6e07.js

js beaky.com.br collected 2026-10-04 15:22:36 UTC 9,803 bytes, 278 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Profile photo upload: presign, PUT straight to storage, then confirm with the
4// server, which is what validates the bytes. The checks here only spare the user
5// a round trip — ProfilePhotoService rejects anything that slips past them.
6const MAX_FILE_SIZE = 5 * 1024 * 1024
7const REQUIRED_WIDTH = 400
8const REQUIRED_HEIGHT = 400
9
10export default class extends Controller {
11  static targets = ["input", "preview", "fallback", "buttonLabel", "progress", "progressBar", "status", "lock", "cropDialog", "canvas", "zoom", "cropSave"]
12
13  static values = {
14    presignUrl: String,
15    completionUrl: String
16  }
17
18  // Everything here toggles the `hidden` ATTRIBUTE, never the class: preflight
19  // gives `[hidden]` a `!important`, while the `hidden` utility ties with the
20  // element's own display utility and loses to any that sorts after it
21  // alphabetically (`inline-flex`, `table`, …), leaving both circles visible.
22  loaded(event) {
23    const wrapper = event.currentTarget.closest("[data-preview-photo]")
24    event.currentTarget.hidden = false
25    this._hide(wrapper?.querySelector("[data-profile-photo-target~='fallback']"), true)
26  }
27
28  failed(event) {
29    const wrapper = event.currentTarget.closest("[data-preview-photo]")
30    event.currentTarget.hidden = true
31    this._hide(wrapper?.querySelector("[data-profile-photo-target~='fallback']"), false)
32  }
33
34  disconnect() {
35    this.selection = null
36    this.cropDialogTarget.close()
37    this.sourceImage = null
38  }
39
40  async select() {
41    const file = this.inputTarget.files[0]
42    if (!file || this.busy) return
43    const selection = this.selection = {}
44    this._setStatus("")
45    this.inputTarget.disabled = true
46
47    try {
48      this._validate(file)
49      const image = await this._loadImage(file)
50      if (this.selection !== selection) return
51      this.sourceImage = image
52      this.sourceFile = file
53      this.centerX = image.naturalWidth / 2
54      this.centerY = image.naturalHeight / 2
55      this.zoomTarget.value = "1"
56      this._drawCrop()
57      this.cropDialogTarget.showModal()
58    } catch (error) {
59      if (this.selection === selection) this._setStatus(error.message)
60    } finally {
61      this.inputTarget.value = ""
62      this.inputTarget.disabled = false
63    }
64  }
65
66  cancelCrop(event) {
67    event?.preventDefault()
68    if (this.busy) return
69    this.cropDialogTarget.close()
70    this.sourceImage = null
71    this.sourceFile = null
72    this.drag = null
73    this.inputTarget.focus()
74  }
75
76  zoom() {
77    this._drawCrop()
78  }
79
80  startDrag(event) {
81    if (this.busy || this.drag || event.button !== 0) return
82    event.preventDefault()
83    this.canvasTarget.focus()
84    this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY }
85    this.canvasTarget.setPointerCapture(event.pointerId)
86  }
87
88  moveDrag(event) {
89    if (!this.drag || this.drag.id !== event.pointerId) return
90    const ratio = this._cropSize() / this.canvasTarget.getBoundingClientRect().width
91    this.centerX -= (event.clientX - this.drag.x) * ratio
92    this.centerY -= (event.clientY - this.drag.y) * ratio
93    this.drag.x = event.clientX
94    this.drag.y = event.clientY
95    this._drawCrop()
96  }
97
98  endDrag(event) {
99    if (this.drag?.id !== event.pointerId) return
100    this.drag = null
101    if (this.canvasTarget.hasPointerCapture(event.pointerId)) this.canvasTarget.releasePointerCapture(event.pointerId)
102  }
103
104  moveWithKeyboard(event) {
105    const direction = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
106    if (!direction || this.busy) return
107    event.preventDefault()
108    const step = this._cropSize() * (event.shiftKey ? 0.1 : 0.02)
109    this.centerX -= direction[0] * step
110    this.centerY -= direction[1] * step
111    this._drawCrop()
112  }
113
114  _cropSize() {
115    return Math.min(this.sourceImage.naturalWidth, this.sourceImage.naturalHeight) / Number(this.zoomTarget.value)
116  }
117
118  _drawCrop() {
119    const size = this._cropSize()
120    this.centerX = Math.max(size / 2, Math.min(this.sourceImage.naturalWidth - size / 2, this.centerX))
121    this.centerY = Math.max(size / 2, Math.min(this.sourceImage.naturalHeight - size / 2, this.centerY))
122    const context = this.canvasTarget.getContext("2d")
123    context.clearRect(0, 0, REQUIRED_WIDTH, REQUIRED_HEIGHT)
124    context.imageSmoothingQuality = "high"
125    context.drawImage(this.sourceImage, this.centerX - size / 2, this.centerY - size / 2, size, size,
126      0, 0, REQUIRED_WIDTH, REQUIRED_HEIGHT)
127  }
128
129  async saveCrop() {
130    if (this.busy || !this.sourceImage) return
131    this.busy = true
132    this.drag = null
133    this.cropSaveTarget.disabled = true
134    this.zoomTarget.disabled = true
135    this.cropSaveTarget.textContent = "Preparando…"
136
137    try {
138      const blob = await new Promise((resolve) => this.canvasTarget.toBlob(resolve, this.sourceFile.type, 0.92))
139      if (!blob) throw new Error("Não foi possível recortar a imagem. Tente novamente.")
140      const extension = blob.type === "image/png" ? "png" : "jpg"
141      // Temporary storage keys must not be reused while an earlier upload's
142      // delayed cleanup may still be pending.
143      const file = new File([blob], `foto-perfil-${crypto.randomUUID()}.${extension}`, { type: blob.type })
144      this._validate(file)
145      this.cropDialogTarget.close()
146      this._setUploading(true)
147
148      const presignData = await this._fetchPresign(file)
149      await this._put(presignData.presigned_url, file)
150      const result = await this._confirm(presignData.object_name, file.size)
151
152      this.previewTargets.forEach((image) => {
153        image.src = result.photo_url
154        image.hidden = false
155      })
156      this.fallbackTargets.forEach((fallback) => { fallback.hidden = true })
157      this.buttonLabelTargets.forEach((label) => { label.textContent = "Trocar foto" })
158      this._setStatus("Foto atualizada.", false)
159    } catch (error) {
160      this.cropDialogTarget.close()
161      this._setStatus(error.message)
162    } finally {
163      this.busy = false
164      this.sourceImage = null
165      this.sourceFile = null
166      this.cropSaveTarget.disabled = false
167      this.zoomTarget.disabled = false
168      this.cropSaveTarget.textContent = "Recortar e salvar"
169      this._setUploading(false)
170      this.inputTarget.focus()
171    }
172  }
173
174  _validate(file) {
175    if (!["image/jpeg", "image/png"].includes(file.type)) {
176      throw new Error("Envie uma imagem JPG ou PNG.")
177    }
178    if (file.size <= 0 || file.size > MAX_FILE_SIZE) {
179      throw new Error("A foto deve ter no máximo 5 MB.")
180    }
181  }
182
183  _loadImage(file) {
184    return new Promise((resolve, reject) => {
185      const url = URL.createObjectURL(file)
186      const image = new Image()
187      image.onload = () => {
188        URL.revokeObjectURL(url)
189        resolve(image)
190      }
191      image.onerror = () => {
192        URL.revokeObjectURL(url)
193        reject(new Error("Não foi possível ler a imagem selecionada."))
194      }
195      image.src = url
196    })
197  }
198
199  async _fetchPresign(file) {
200    const url = new URL(this.presignUrlValue, window.location.origin)
201    url.searchParams.set("filename", file.name)
202    url.searchParams.set("size", file.size)
203    url.searchParams.set("content_type", file.type)
204
205    const response = await fetch(url, {
206      headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }
207    })
208    return this._responseJson(response, "Não foi possível iniciar o envio.")
209  }
210
211  _put(presignedUrl, file) {
212    return new Promise((resolve, reject) => {
213      const request = new XMLHttpRequest()
214      request.open("PUT", presignedUrl)
215      request.setRequestHeader("Content-Type", file.type)
216      request.upload.addEventListener("progress", (event) => {
217        if (event.lengthComputable) {
218          this.progressBarTarget.style.width = `${Math.round((event.loaded / event.total) * 100)}%`
219        }
220      })
221      request.addEventListener("load", () => {
222        if (request.status >= 200 && request.status < 300) resolve()
223        else reject(new Error("Falha ao enviar a foto para o armazenamento."))
224      })
225      request.addEventListener("error", () => reject(new Error("Erro de rede ao enviar a foto.")))
226      request.send(file)
227    })
228  }
229
230  async _confirm(objectName, size) {
231    const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
232    const response = await fetch(this.completionUrlValue, {
233      method: "POST",
234      headers: {
235        "Content-Type": "application/x-www-form-urlencoded",
236        "Accept": "application/json",
237        "X-CSRF-Token": csrfToken || "",
238        "X-Requested-With": "XMLHttpRequest"
239      },
240      body: new URLSearchParams({ object_name: objectName, size })
241    })
242    return this._responseJson(response, "Não foi possível salvar a foto.")
243  }
244
245  async _responseJson(response, fallbackMessage) {
246    let data
247
248    try {
249      data = await response.json()
250    } catch {
251      throw new Error(fallbackMessage)
252    }
253
254    if (!response.ok) throw new Error(data.error || fallbackMessage)
255
256    return data
257  }
258
259  // `lock` is whatever else on the page must not be used mid-upload — the
260  // marketing form's submit button, for instance. Pages without one lock nothing.
261  _setUploading(uploading) {
262    this._hide(this.progressTarget, !uploading)
263    this.progressBarTarget.style.width = uploading ? "0%" : "100%"
264    this.inputTarget.disabled = uploading
265    this.lockTargets.forEach((element) => { element.disabled = uploading })
266  }
267
268  _setStatus(message, isError = true) {
269    this.statusTarget.textContent = message
270    this._hide(this.statusTarget, !message)
271    this.statusTarget.classList.toggle("text-red-600", isError)
272    this.statusTarget.classList.toggle("text-emerald-700", !isError)
273  }
274
275  _hide(element, hidden) {
276    if (element) element.hidden = hidden
277  }
278};

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.