1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["body", "input", "error"] 5 6 static values = { 7 maxFiles: Number, 8 maxFileBytes: Number, 9 maxTotalBytes: Number, 10 allowedTypes: String, 11 storagePrefix: String, 12 restore: Boolean, 13 } 14 15 connect() { 16 if (this.restoreValue) { 17 this.restoreBody() 18 this.removeRestoreFlag() 19 } else { 20 this.clearSavedBodies() 21 } 22 } 23 24 check() { 25 this.showError(this.validationMessage()) 26 } 27 28 validate(event) { 29 const message = this.validationMessage() 30 if (message) { 31 event.preventDefault() 32 event.stopImmediatePropagation() 33 this.showError(message, { focus: true }) 34 return 35 } 36 37 this.showError(null) 38 this.saveBody() 39 } 40 41 validationMessage() { 42 const files = this.files 43 if (files.length > this.maxFilesValue) { 44 return `Choose no more than ${this.maxFilesValue} images.` 45 } 46 47 const allowedTypes = this.allowedTypesValue.split(",") 48 const unsupported = files.find((file) => !allowedTypes.includes(file.type)) 49 if (unsupported) { 50 return `${unsupported.name} is not supported. Choose a JPG, PNG, WebP, or GIF image.` 51 } 52 53 const oversized = files.find((file) => file.size > this.maxFileBytesValue) 54 if (oversized) { 55 return `${oversized.name} is too large. Each image can be up to ${this.megabytes(this.maxFileBytesValue)}.` 56 } 57 58 const totalBytes = files.reduce((total, file) => total + file.size, 0) 59 if (totalBytes > this.maxTotalBytesValue) { 60 return `These images total more than ${this.megabytes(this.maxTotalBytesValue)}. Choose fewer images for this request.` 61 } 62 63 return null 64 } 65 66 get files() { 67 return this.inputTargets.flatMap((input) => Array.from(input.files || [])) 68 } 69 70 get storageKey() { 71 return `${this.storagePrefixValue}:${this.element.action}` 72 } 73 74 showError(message, { focus = false } = {}) { 75 if (!this.hasErrorTarget) return 76 77 this.errorTarget.textContent = message || "" 78 this.errorTarget.classList.toggle("hidden", !message) 79 if (message && focus) this.errorTarget.focus() 80 } 81 82 saveBody() { 83 if (!this.hasBodyTarget) return 84 85 this.writeStorage(this.storageKey, this.bodyTarget.value) 86 } 87 88 restoreBody() { 89 if (!this.hasBodyTarget || this.bodyTarget.value) return 90 91 const saved = this.readStorage(this.storageKey) 92 if (saved) this.bodyTarget.value = saved 93 this.removeStorage(this.storageKey) 94 } 95 96 clearSavedBodies() { 97 this.storageKeys() 98 .filter((key) => key.startsWith(`${this.storagePrefixValue}:`)) 99 .forEach((key) => this.removeStorage(key)) 100 } 101 102 removeRestoreFlag() { 103 const url = new URL(window.location.href) 104 if (!url.searchParams.has("restore_request")) return 105 106 url.searchParams.delete("restore_request") 107 window.history.replaceState(window.history.state, "", url.toString()) 108 } 109 110 storageKeys() { 111 try { 112 return Array.from({ length: window.sessionStorage.length }, (_, index) => window.sessionStorage.key(index)).filter(Boolean) 113 } catch (_error) { 114 return [] 115 } 116 } 117 118 readStorage(key) { 119 try { 120 return window.sessionStorage.getItem(key) 121 } catch (_error) { 122 return null 123 } 124 } 125 126 writeStorage(key, value) { 127 try { 128 window.sessionStorage.setItem(key, value) 129 } catch (_error) { 130 // The request can still be submitted when private storage is unavailable. 131 } 132 } 133 134 removeStorage(key) { 135 try { 136 window.sessionStorage.removeItem(key) 137 } catch (_error) { 138 // Nothing to clean up when private storage is unavailable. 139 } 140 } 141 142 megabytes(bytes) { 143 return `${Math.round(bytes / 1024 / 1024)} MB` 144 } 145}
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.