1import { Controller } from "@hotwired/stimulus" 2 3// Drives the language exchange setup form: instant/scheduled mode cards, 4// the duration preset stepper, the equal-split labels, and schedule 5// serialization. UTC ISO8601 is written to a separate hidden field on submit 6// so the sender's device clock decides the instant while the visible 7// datetime-local value stays valid. Without JavaScript, the raw local value 8// falls back to the server zone. 9export default class extends Controller { 10 static targets = [ 11 "modeCard", "modeRadio", "scheduledFields", "scheduledAt", "scheduledAtUtc", 12 "durationInput", "durationLabel", "submitLabel", "partnerTime", 13 "teachSelect", "learnSelect", "viewerPairLanguage", "partnerPairLanguage", "pairMinutes" 14 ] 15 static values = { 16 presets: { type: Array, default: [10, 15, 20, 30] }, 17 minutesTemplate: String, 18 splitTemplate: { type: String, default: "%{minutes} mins each" }, 19 splitSecondsTemplate: { type: String, default: "%{minutes} min %{seconds} sec each" }, 20 instantLabel: String, 21 scheduledLabel: String, 22 partnerZone: { type: String, default: "" }, 23 partnerTimeTemplate: { type: String, default: "" } 24 } 25 26 connect() { 27 this.render() 28 } 29 30 modeChanged() { 31 this.render() 32 } 33 34 scheduleChanged() { 35 this.updatePartnerTime() 36 } 37 38 // "Partner's time: 14:36" -- only when their device has reported a zone. 39 updatePartnerTime() { 40 if (!this.hasPartnerTimeTarget) return 41 const raw = this.hasScheduledAtTarget ? this.scheduledAtTarget.value : "" 42 if (!this.partnerZoneValue || !raw) { 43 this.partnerTimeTarget.hidden = true 44 return 45 } 46 47 const parsed = new Date(raw) 48 if (Number.isNaN(parsed.getTime())) { 49 this.partnerTimeTarget.hidden = true 50 return 51 } 52 53 let formatted 54 try { 55 formatted = new Intl.DateTimeFormat(undefined, { 56 timeZone: this.partnerZoneValue, 57 hour: "2-digit", 58 minute: "2-digit", 59 hour12: false 60 }).format(parsed) 61 } catch (_error) { 62 this.partnerTimeTarget.hidden = true 63 return 64 } 65 66 this.partnerTimeTarget.textContent = this.partnerTimeTemplateValue.replace("%{time}", formatted) 67 this.partnerTimeTarget.hidden = false 68 } 69 70 decreaseDuration() { 71 this.stepDuration(-1) 72 } 73 74 increaseDuration() { 75 this.stepDuration(1) 76 } 77 78 serializeSchedule() { 79 if (this.hasScheduledAtUtcTarget) this.scheduledAtUtcTarget.value = "" 80 if (this.mode() !== "scheduled" || !this.hasScheduledAtTarget || !this.hasScheduledAtUtcTarget) return 81 82 const raw = this.scheduledAtTarget.value 83 if (!raw) return 84 85 const parsed = new Date(raw) 86 if (Number.isNaN(parsed.getTime())) return 87 88 this.scheduledAtUtcTarget.value = parsed.toISOString() 89 } 90 91 mode() { 92 const checked = this.modeRadioTargets.find((radio) => radio.checked) 93 return checked ? checked.value : "instant" 94 } 95 96 stepDuration(direction) { 97 const current = parseInt(this.durationInputTarget.value, 10) 98 const index = this.presetsValue.indexOf(current) 99 const nextIndex = Math.min(Math.max((index === -1 ? 0 : index) + direction, 0), this.presetsValue.length - 1) 100 this.durationInputTarget.value = this.presetsValue[nextIndex] 101 this.render() 102 } 103 104 render() { 105 const scheduled = this.mode() === "scheduled" 106 const minutes = parseInt(this.durationInputTarget.value, 10) || this.presetsValue[0] 107 108 if (this.hasScheduledFieldsTarget) this.scheduledFieldsTarget.hidden = !scheduled 109 if (this.hasDurationLabelTarget) { 110 this.durationLabelTarget.textContent = this.minutesTemplateValue.replace("%{minutes}", String(minutes)) 111 } 112 // Each avatar carries the language that member brings, benchmark-style: 113 // the viewer's side mirrors "I teach", the partner's mirrors "teach me". 114 if (this.hasViewerPairLanguageTarget && this.hasTeachSelectTarget) { 115 this.viewerPairLanguageTarget.textContent = this.selectedLabel(this.teachSelectTarget) 116 } 117 if (this.hasPartnerPairLanguageTarget && this.hasLearnSelectTarget) { 118 this.partnerPairLanguageTarget.textContent = this.selectedLabel(this.learnSelectTarget) 119 } 120 const halfLabel = this.halfLabel(minutes)
121 this.pairMinutesTargets.forEach((label) => { 122 label.textContent = halfLabel 123 }) 124 if (this.hasSubmitLabelTarget) { 125 this.submitLabelTarget.textContent = scheduled ? this.scheduledLabelValue : this.instantLabelValue 126 } 127 this.modeCardTargets.forEach((card) => { 128 const selected = card.dataset.exchangeModeCard === this.mode() 129 card.style.borderColor = selected ? "var(--kb-primary)" : "var(--kb-border)" 130 }) 131 this.updatePartnerTime() 132 } 133 134 selectedLabel(select) { 135 const option = select.selectedOptions && select.selectedOptions[0] 136 return option ? option.textContent : select.value 137 } 138 139 // An odd preset splits into minutes AND seconds: 15 mins reads as 140 // "7 min 30 sec each", never "7.5". 141 halfLabel(minutes) { 142 const half = minutes / 2 143 if (Number.isInteger(half)) { 144 return this.splitTemplateValue.replace("%{minutes}", String(half)) 145 } 146 147 return this.splitSecondsTemplateValue 148 .replace("%{minutes}", String(Math.floor(half))) 149 .replace("%{seconds}", "30") 150 } 151}
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.