1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = [ 5 "recipientSelect", "recipientCount", 6 "messageTypeSection", "messageTypeRadio", 7 "messageSection", "messageInput", "charCount", 8 "sendBtn", "sendText", 9 "successState", "successText" 10 ] 11 12 static values = { 13 tournamentId: Number 14 } 15 16 connect() { 17 console.log("Tournament broadcast controller connected") 18 this.updateCharCount() 19 // Register global method for triggering from outside controller scope 20 window.openTournamentBroadcast = () => this.open() 21 } 22 23 disconnect() { 24 delete window.openTournamentBroadcast 25 } 26 27 open() { 28 this.element.style.display = "flex" 29 document.body.style.overflow = "hidden" 30 this.resetForm() 31 // Focus message input after animation 32 setTimeout(() => { 33 if (this.hasMessageInputTarget) { 34 this.messageInputTarget.focus() 35 } 36 }, 100) 37 } 38 39 close() { 40 this.element.style.display = "none" 41 document.body.style.overflow = "" 42 this.resetForm() 43 } 44 45 resetForm() { 46 if (this.hasMessageInputTarget) { 47 this.messageInputTarget.value = "" 48 } 49 if (this.hasRecipientSelectTarget) { 50 this.recipientSelectTarget.value = "all" 51 } 52 if (this.hasMessageTypeSectionTarget) { 53 this.messageTypeSectionTarget.style.display = "none" 54 } 55 if (this.hasMessageSectionTarget) { 56 this.messageSectionTarget.style.display = "block" 57 } 58 if (this.hasSuccessStateTarget) { 59 this.successStateTarget.style.display = "none" 60 } 61 // Reset message type radios to custom 62 this.messageTypeRadioTargets.forEach(radio => { 63 radio.checked = radio.value === "custom" 64 }) 65 this.updateCharCount() 66 this.updateRecipientCount() 67 this.validateForm() 68 } 69 70 updateRecipientCount() { 71 if (!this.hasRecipientSelectTarget || !this.hasRecipientCountTarget) return 72 73 const selectedOption = this.recipientSelectTarget.selectedOptions[0] 74 const count = selectedOption?.dataset.count || "0" 75 this.recipientCountTarget.textContent = count 76 77 // Show/hide message type section based on selection 78 const isAllParticipants = this.recipientSelectTarget.value === "all" 79 if (this.hasMessageTypeSectionTarget) { 80 this.messageTypeSectionTarget.style.display = isAllParticipants ? "none" : "block" 81 } 82 83 // Update send button text 84 if (this.hasSendTextTarget) { 85 this.sendTextTarget.textContent = `Send to ${count}` 86 } 87 88 this.validateForm() 89 } 90 91 toggleMessageType() { 92 const selectedType = this.messageTypeRadioTargets.find(r => r.checked)?.value || "custom" 93 94 if (this.hasMessageSectionTarget) { 95 // Show message input only for custom messages 96 this.messageSectionTarget.style.display = selectedType === "custom" ? "block" : "none" 97 } 98 99 this.validateForm() 100 } 101 102 updateCharCount() { 103 if (!this.hasMessageInputTarget || !this.hasCharCountTarget) return 104 105 const length = this.messageInputTarget.value.length 106 this.charCountTarget.textContent = length 107 108 // Change color when approaching limit 109 if (length > 180) { 110 this.charCountTarget.parentElement.style.color = "var(--pb-color-warning)" 111 } else { 112 this.charCountTarget.parentElement.style.color = "" 113 } 114 115 this.validateForm() 116 } 117 118 validateForm() { 119 if (!this.hasSendBtnTarget) return 120 121 const isAllParticipants = this.recipientSelectTarget?.value === "all" 122 const selectedType = this.messageTypeRadioTargets.find(r => r.checked)?.value || "custom" 123 124 // For all participants, always require custom message 125 // For divisions, require message only if custom type selected 126 const needsMessage = isAllParticipants || selectedType === "custom" 127 const hasMessage = this.messageInputTarget?.value.trim().length > 0 128 129 const isValid = !needsMessage || hasMessage 130 131 this.sendBtnTarget.disabled = !isValid 132 } 133 134 async send() { 135 if (!this.hasSendBtnTarget || this.sendBtnTarget.disabled) return 136 137 const sendBtn = this.sendBtnTarget 138 const originalText = this.sendTextTarget?.textContent || "Send" 139 140 // Show loading state 141 sendBtn.disabled = true 142 if (this.hasSendTextTarget) { 143 this.sendTextTarget.innerHTML = `
144 <svg class="spinning" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 145 <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/> 146 </svg> 147 Sending... 148 ` 149 } 150 151 try { 152 const isAllParticipants = this.recipientSelectTarget?.value === "all" 153 const divisionId = isAllParticipants ? null : this.recipientSelectTarget?.value 154 const message = this.messageInputTarget?.value.trim() 155 const messageType = this.messageTypeRadioTargets.find(r => r.checked)?.value || "custom" 156 157 let response 158 if (isAllParticipants) { 159 // Broadcast to all tournament participants 160 response = await fetch(`/api/tournaments/${this.tournamentIdValue}/broadcast`, { 161 method: "POST", 162 headers: { 163 "Content-Type": "application/json", 164 "Accept": "application/json", 165 "X-CSRF-Token": this.getCSRFToken() 166 }, 167 body: JSON.stringify({ 168 message: message, 169 include_pending: true 170 }) 171 }) 172 } else { 173 // Broadcast to specific division 174 response = await fetch(`/api/tournaments/${this.tournamentIdValue}/divisions/${divisionId}/broadcast`, { 175 method: "POST", 176 headers: { 177 "Content-Type": "application/json", 178 "Accept": "application/json", 179 "X-CSRF-Token": this.getCSRFToken() 180 }, 181 body: JSON.stringify({ 182 message_type: messageType, 183 custom_message: messageType === "custom" ? message : null, 184 include_pending: true 185 }) 186 }) 187 } 188 189 const data = await response.json() 190 191 if (!response.ok) { 192 throw new Error(data.error || "Failed to send broadcast") 193 } 194 195 // Show success state 196 this.showSuccess(data.sent_count || data.total_participants || 0) 197 198 } catch (error) { 199 console.error("Error sending broadcast:", error) 200 alert(error.message || "Failed to send broadcast") 201 202 // Reset button 203 sendBtn.disabled = false 204 if (this.hasSendTextTarget) { 205 this.sendTextTarget.textContent = originalText 206 } 207 } 208 } 209 210 showSuccess(sentCount) { 211 // Hide form elements 212 const formElements = this.element.querySelectorAll( 213 ".tournament-broadcast-modal__recipient, " + 214 ".tournament-broadcast-modal__message-type, " + 215 ".tournament-broadcast-modal__message, " + 216 ".tournament-broadcast-modal__warning, " + 217 ".tournament-broadcast-modal__footer" 218 ) 219 formElements.forEach(el => el.style.display = "none") 220 221 // Show success state 222 if (this.hasSuccessStateTarget) { 223 this.successStateTarget.style.display = "flex" 224 } 225 if (this.hasSuccessTextTarget) { 226 this.successTextTarget.textContent = `Your message was sent to ${sentCount} participant${sentCount !== 1 ? "s" : ""}.` 227 } 228 } 229 230 getCSRFToken() { 231 const meta = document.querySelector('meta[name="csrf-token"]') 232 return meta ? meta.content : "" 233 } 234};
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.