1import { Controller } from '@hotwired/stimulus' 2import { PhotoRatingService } from '../../../services/Project/Gallery/PhotoRatingService.js' 3import { 4 RATING_MAYBE, 5 RATING_NO, 6 RATING_UNRATED, 7 RATING_YES, 8 isValidRating, 9 normalizeRating, 10 resolveRatingArrowDown, 11 resolveRatingArrowUp, 12 resolveRatingClick, 13} from '../../../utils/photoRating.js' 14import { updatePresentStripThumbRating } from '../../../utils/presentStrip.js' 15 16/* stimulusFetch: 'lazy' */ 17export default class extends Controller { 18 static targets = ['yesButton', 'maybeButton', 'noButton', 'error'] 19 20 static RATING_ERROR_MESSAGE = "Couldn't save rating. Please try again." 21 22 static values = { 23 projectId: String, 24 } 25 26 #ratingRequestId = 0 27 28 connect() { 29 this.ratingService = new PhotoRatingService() 30 this.activePhotoId = null 31 this.activeRating = RATING_UNRATED 32 this.#ratingRequestId = 0 33 this.#setButtonsDisabled(true) 34 35 requestAnimationFrame(() => this.#syncFromStripSelection()) 36 } 37 38 onPhotoSelected(event) { 39 // Changing photos invalidates any in-flight rating save, so a late failure 40 // for the previous photo can't fire its error/rollback over this one. 41 this.#ratingRequestId += 1 42 const photoId = this.#normalizePhotoId(event.detail?.photoId) 43 this.activePhotoId = photoId || null 44 this.activeRating = normalizeRating(event.detail?.rating) 45 this.#setButtonsDisabled(!this.activePhotoId) 46 this.#updateActiveButtons() 47 this.#clearError() 48 } 49 50 onRatingClick(event) { 51 const button = event.target.closest('[data-rating]') 52 if (!button || !this.activePhotoId) return 53 54 const clicked = Number.parseInt(button.dataset.rating, 10) 55 const nextRating = resolveRatingClick(this.activeRating, clicked) 56 this.#applyRating(nextRating) 57 } 58 59 onRatingKeydown(event) { 60 if (!this.#shouldHandleKey(event) || !this.activePhotoId) return 61 62 let nextRating = null 63 64 if (event.key === 'ArrowUp') { 65 event.preventDefault() 66 nextRating = resolveRatingArrowUp(this.activeRating) 67 } else if (event.key === 'ArrowDown') { 68 event.preventDefault() 69 nextRating = resolveRatingArrowDown(this.activeRating) 70 } else { 71 const keyRatings = { 72 0: 0, 73 1: 1, 74 2: 2, 75 3: 3, 76 } 77 if (!(event.key in keyRatings)) return 78 event.preventDefault() 79 nextRating = keyRatings[event.key] 80 } 81 82 this.#applyRating(nextRating) 83 } 84 85 #shouldHandleKey(event) { 86 const tag = event.target?.tagName?.toLowerCase() 87 if (tag === 'input' || tag === 'textarea' || tag === 'select') return false 88 if (event.target?.isContentEditable) return false 89 return true 90 } 91 92 async #applyRating(rating) { 93 const nextRating = normalizeRating(rating) 94 if ( 95 !this.projectIdValue || 96 !this.activePhotoId || 97 !isValidRating(nextRating) 98 ) { 99 return 100 } 101 102 const photoId = this.activePhotoId 103 const previous = normalizeRating(this.activeRating) 104 const reviewRoot = this.element.closest('.hd-PresentReview') 105 const requestId = ++this.#ratingRequestId 106 107 this.activeRating = nextRating 108 this.#updateActiveButtons() 109 this.#setButtonsDisabled(true) 110 this.#clearError() 111 updatePresentStripThumbRating(reviewRoot, photoId, nextRating) 112 113 try { 114 await this.ratingService.updateRating( 115 this.projectIdValue, 116 photoId, 117 nextRating 118 ) 119 if (requestId !== this.#ratingRequestId) return 120 this.#emitRated(photoId, nextRating) 121 } catch (error) { 122 if (requestId !== this.#ratingRequestId) return 123 // Roll the optimistic UI back to the last persisted rating and tell the 124 // reviewer, so a failed save isn't mistaken for a recorded one. 125 this.activeRating = previous 126 this.#updateActiveButtons() 127 updatePresentStripThumbRating(reviewRoot, photoId, previous) 128 this.#showError() 129 console.warn('Failed to update photo rating', error) 130 } finally { 131 if (requestId === this.#ratingRequestId) { 132 this.#setButtonsDisabled(!this.activePhotoId) 133 } 134 } 135 } 136 137 #showError() { 138 if (!this.hasErrorTarget) return 139 this.errorTarget.textContent = this.constructor.RATING_ERROR_MESSAGE 140 this.errorTarget.hidden = false 141 } 142 143 #clearError() { 144 if (!this.hasErrorTarget) return 145 this.errorTarget.textContent = '' 146 this.errorTarget.hidden = true 147 } 148 149 #emitRated(photoId, rating) { 150 window.dispatchEvent( 151 new CustomEvent('present-rating:rated', { 152 bubbles: true, 153 detail: { photoId: this.#normalizePhotoId(photoId), rating }, 154 }) 155 ) 156 } 157 158 #updateActiveButtons() { 159 const active = normalizeRating(this.activeRating) 160 const map = { 161 [RATING_YES]: this.yesButtonTarget, 162 [RATING_MAYBE]: this.maybeButtonTarget, 163 [RATING_NO]: this.noButtonTarget, 164 } 165 166 for (const [value, btn] of Object.entries(map)) { 167 const rating = Number.parseInt(value, 10) 168 const isActive = active === rating 169 btn.classList.toggle('is-active', isActive) 170 btn.setAttribute('aria-pressed', isActive ? 'true' : 'false') 171 btn.style.removeProperty('border-color') 172 } 173 } 174 175 #setButtonsDisabled(disabled) { 176 ;[ 177 this.yesButtonTarget, 178 this.maybeButtonTarget, 179 this.noButtonTarget, 180 ].forEach((btn) => { 181 btn.disabled = disabled 182 }) 183 } 184 185 #syncFromStripSelection() { 186 const root = this.element.closest('.hd-PresentReview') 187 if (!root) return 188 189 const thumb = root.querySelector(
190 '.hd-PresentStrip__thumb .hd-PresentStrip__image.is-selected' 191 ) 192 if (!thumb) return 193 194 const stripButton = thumb.closest('.hd-PresentStrip__thumb') 195 if (!stripButton?.dataset.photoId) return 196 197 this.onPhotoSelected({ 198 detail: { 199 photoId: stripButton.dataset.photoId, 200 rating: Number.parseInt(stripButton.dataset.rating ?? '0', 10), 201 }, 202 }) 203 } 204 205 #normalizePhotoId(id) { 206 if (id === undefined || id === null || id === '') return '' 207 return String(id) 208 } 209}
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.