PageSourceSearch

https://saii.kr/assets/controllers/exchange_form_controller-76165291.js

js saii.kr collected 2026-10-06 03:45:08 UTC 5,241 bytes, 151 lines download raw bytes

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.