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.