@ihabdevteam/core 0.132.0 → 0.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +2 -2
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +8 -13
  8. package/dist/packages/core/src/components/BarChart.js +2 -2
  9. package/dist/packages/core/src/components/BottomNav.js +3 -3
  10. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  11. package/dist/packages/core/src/components/BulkBar.js +1 -1
  12. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  13. package/dist/packages/core/src/components/CodeBox.js +1 -1
  14. package/dist/packages/core/src/components/DatePicker.js +16 -3
  15. package/dist/packages/core/src/components/Dialog.js +32 -9
  16. package/dist/packages/core/src/components/Dropdown.js +13 -3
  17. package/dist/packages/core/src/components/FileUpload.js +8 -7
  18. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  19. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  20. package/dist/packages/core/src/components/Header.css +2 -0
  21. package/dist/packages/core/src/components/Header.js +4 -1
  22. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  23. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  24. package/dist/packages/core/src/components/PageTitle.js +1 -1
  25. package/dist/packages/core/src/components/Pagination.js +6 -3
  26. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  27. package/dist/packages/core/src/components/Panel.js +4 -1
  28. package/dist/packages/core/src/components/PanelRow.js +3 -3
  29. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  30. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  31. package/dist/packages/core/src/components/PaymentPlan.js +6 -4
  32. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  33. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  34. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  35. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  36. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  37. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  38. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  39. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  40. package/dist/packages/core/src/components/SideNav.js +2 -2
  41. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  42. package/dist/packages/core/src/components/Table.js +2 -2
  43. package/dist/packages/core/src/components/Tabs.js +7 -2
  44. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +8 -3
  45. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  46. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  47. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  50. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  51. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  52. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  53. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  54. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  55. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  56. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  57. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  58. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  60. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  61. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  62. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  63. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  64. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  65. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  66. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +6 -5
  67. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  68. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  69. package/dist/packages/core/src/components/WordTable.js +6 -6
  70. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  71. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  72. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  73. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  74. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  75. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  76. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  77. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  78. package/dist/packages/core/src/i18n/locales/en.json +11 -0
  79. package/dist/packages/core/src/i18n/locales/ko.json +11 -0
  80. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  81. package/dist/packages/core/src/utils/csv.js +3 -1
  82. package/dist/packages/core/src/utils/format.js +17 -1
  83. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  84. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  85. package/dist/packages/core/src/utils/pressable.js +4 -0
  86. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  87. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  88. package/dist/packages/core/src/utils/textSort.js +7 -2
  89. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  90. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  91. package/dist/packages/core/src/utils/wordTrainingRuntime.js +55 -17
  92. package/dist/packages/core/src/version.d.ts +1 -1
  93. package/dist/packages/core/src/version.js +1 -1
  94. package/dist/utils.cjs.js +2 -2
  95. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { createPortal } from "react-dom";
7
7
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
8
+ import { pushEsc } from '../../hooks/escStack';
8
9
  import Toast from '../Toast';
9
10
  import Button from '../Button';
10
11
  import './TrainingPagePopup.css';
@@ -122,17 +123,29 @@ function TrainingPagePopupInner(props) {
122
123
  // 이 판에만 거는 설정 — 마운트 때 한 번 붙잡는다(부모가 매 렌더 새 함수를 넘겨도 다시 걸지 않게).
123
124
  // 있으면 이 판은 저장을 건너뛴다(mergeAndSaveSettings 참고).
124
125
  const noiseOverrideRef = useRef(noiseOverride);
126
+ /* 불러오기가 끝났나 · 끝나기 전에 바뀐 것. 끝나기 전의 변경은 **저장하지 않고 모아 두었다가** 불러온
127
+ 값 위에 얹어 한 번 저장한다. 전에는 enabled:false 초기값 위에 합쳐 곧바로 저장해(소음 꺼짐이 DB 에
128
+ 남아 다음 판부터 소음이 안 났다) 늦게 온 불러오기가 그 변경을 다시 덮었다. */
129
+ const noiseLoadedRef = useRef(false);
130
+ const pendingNoiseRef = useRef(null);
125
131
  // 계정/고객 저장소에서 초기값 로드 (앱이 configureNoiseSettingsStore로 주입)
126
132
  useEffect(() => {
127
133
  let cancelled = false;
128
- void loadNoiseSettings().then((saved) => {
134
+ const apply = (saved) => {
129
135
  if (cancelled)
130
136
  return;
137
+ noiseLoadedRef.current = true;
131
138
  // 저장된 설정이 있으면 병합, 없으면 진짜 기본값으로 확정 — 어느 쪽이든 이제부터 재생 허용.
132
139
  // mergeNoiseSettings — noiseRatio 가 없는 옛 기록은 기본값 비율(1)에 가리지 않고 level 에서 옮긴다.
133
- const merged = saved ? mergeNoiseSettings(noiseSettingsRef.current, saved) : { ...DEFAULT_NOISE_SETTINGS };
140
+ // 바탕은 **처음 값**이다 — 끝나기 전 변경은 pending 으로 따로 얹는다(아래).
141
+ const loaded = saved ? mergeNoiseSettings({ ...DEFAULT_NOISE_SETTINGS, enabled: false }, saved) : { ...DEFAULT_NOISE_SETTINGS };
142
+ const pending = pendingNoiseRef.current;
143
+ pendingNoiseRef.current = null;
144
+ const merged = pending ? mergeNoiseSettings(loaded, pending) : loaded;
134
145
  noiseSettingsRef.current = merged;
135
146
  setNoisePart(merged);
147
+ if (pending && !noiseOverrideRef.current)
148
+ saveNoiseSettings(merged);
136
149
  reportAppliedNoiseSettings(merged);
137
150
  // override 는 병합이 끝난 **뒤에** 덮는다 — 로드가 늦게 와도 override 가 이긴다.
138
151
  const override = noiseOverrideRef.current;
@@ -143,7 +156,8 @@ function TrainingPagePopupInner(props) {
143
156
  setNoisePart(applied);
144
157
  reportAppliedNoiseSettings(applied);
145
158
  }
146
- });
159
+ };
160
+ void loadNoiseSettings().then(apply);
147
161
  return () => { cancelled = true; };
148
162
  }, []);
149
163
  // 오디오 레벨 미터용 analyser (오디오 플레이어가 재생 시 등록)
@@ -160,6 +174,12 @@ function TrainingPagePopupInner(props) {
160
174
  const merged = mergeNoiseSettings(noiseSettingsRef.current, next);
161
175
  noiseSettingsRef.current = merged;
162
176
  setNoisePart(merged);
177
+ if (!noiseLoadedRef.current) {
178
+ // 불러오기 전 — 화면에는 곧바로 걸되 저장은 불러온 뒤로 미룬다(위 apply).
179
+ pendingNoiseRef.current = { ...(pendingNoiseRef.current ?? {}), ...next };
180
+ reportAppliedNoiseSettings(merged);
181
+ return;
182
+ }
163
183
  // noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
164
184
  if (!noiseOverrideRef.current)
165
185
  saveNoiseSettings(merged);
@@ -199,31 +219,39 @@ function TrainingPagePopupInner(props) {
199
219
  }, []);
200
220
  useEffect(() => () => { if (closeTimerRef.current)
201
221
  clearTimeout(closeTimerRef.current); }, []);
202
- // Focus first focusable element when popup opens. 가둠은 useFocusTrap 이 한다.
222
+ /* 열 때 첫 칸으로 포커스 · 닫힐 때 연 곳으로 되돌림 — **마운트 때 한 번만.** 가둠은 useFocusTrap 이 한다.
223
+ 전에는 이 효과가 onBack 에 걸려 있어 부모가 새 onBack 을 넘길 때마다 다시 돌아 **훈련 중 포커스를 첫 칸으로
224
+ 끌어왔고**, 닫을 때 포커스를 되돌리는 코드는 없어 뒤 화면에서 포커스를 잃었다. */
225
+ const onBackRef = useRef(onBack);
226
+ useEffect(() => { onBackRef.current = onBack; });
203
227
  useEffect(() => {
204
228
  const panel = panelRef.current;
205
- if (!panel)
206
- return;
207
- const getFocusableEls = () => Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'));
208
- // Focus first focusable element
209
- const firstEl = getFocusableEls()[0];
210
- if (firstEl) {
211
- try {
212
- firstEl.focus();
229
+ const previouslyFocused = document.activeElement;
230
+ if (panel) {
231
+ const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
232
+ if (firstEl) {
233
+ try {
234
+ firstEl.focus();
235
+ }
236
+ catch (e) { }
213
237
  }
214
- catch (e) { }
215
238
  }
216
- const handleKeyDown = (e) => {
217
- if (e.key === 'Escape') {
218
- e.preventDefault();
219
- scheduleClose(() => { if (typeof onBack === 'function')
220
- onBack(); });
221
- return;
239
+ return () => {
240
+ // 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
241
+ const active = document.activeElement;
242
+ if (!active || active === document.body || (panel && panel.contains(active))) {
243
+ try {
244
+ previouslyFocused?.focus?.();
245
+ }
246
+ catch (e) { }
222
247
  }
223
248
  };
224
- panel.addEventListener('keydown', handleKeyDown);
225
- return () => panel.removeEventListener('keydown', handleKeyDown);
226
- }, [onBack, scheduleClose]);
249
+ }, []);
250
+ /* ESC — **공유 ESC 스택**에 올린다(Popup·메뉴·툴팁과 같은 스택). 전에는 판(panel)의 네이티브 keydown 만 들었는데
251
+ 판에 tabIndex 가 없어, 문제 글·그림을 눌러 포커스가 body 로 가면 Esc 가 먹지 않았고, 훈련 창이 다른 팝업 위에
252
+ 떠 있을 때는 그 팝업의 document 리스너가 가려진 아래 팝업을 닫았다. */
253
+ useEffect(() => pushEsc(() => scheduleClose(() => { const back = onBackRef.current; if (typeof back === 'function')
254
+ back(); })), [scheduleClose]);
227
255
  /* Tab 가둠은 훅에 맡긴다 — 여기 있던 열두 줄은 `useFocusTrap` 의 것과 같은
228
256
  내용이었는데 **고르개 문자열만 제 안에 따로 박혀 있었다**(공백 없는
229
257
  `FOCUSABLE_SELECTORS`). 훅 머리말이 "네 곳을 모았다"고 적어 둔 그 넷 중
@@ -790,7 +818,7 @@ function TrainingPagePopupInner(props) {
790
818
  catch (e) { }
791
819
  },
792
820
  onClose: handleResultDone,
793
- })), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
821
+ })), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { htmlType: "button", variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
794
822
  const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
795
823
  if (typeof document !== 'undefined' && document.body)
796
824
  return createPortal(portalContent, document.body);
@@ -56,5 +56,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
56
56
  setOpen(true);
57
57
  }, [open, onSettings]);
58
58
  const handleToggleMeters = useCallback(() => setShowMeters(prev => !prev), []);
59
- return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
59
+ return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
60
60
  }
@@ -37,5 +37,5 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
37
37
  }
38
38
  return base;
39
39
  }, [wrapperStyle]);
40
- return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
40
+ return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { htmlType: "button", size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
41
41
  }
@@ -163,8 +163,18 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
163
163
  const multiSources = Array.isArray(sources) && sources.length > 0 ? sources : (Array.isArray(segments) && segments.length > 0 ? segments : null);
164
164
  const sourceButtonCount = Math.max(Array.isArray(multiSources) ? multiSources.length : 0, requestedSources.length);
165
165
  // resolveMapping 제거됨 — JSON 파일 의존성 없이 resolveSoundPath(code, bucketPath) 로 직접 경로 결정
166
+ /* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
167
+ 팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
168
+ 울렸다. 재생 직전에 본다. */
169
+ const mountedRef = useRef(true);
170
+ useEffect(() => {
171
+ mountedRef.current = true;
172
+ return () => { mountedRef.current = false; };
173
+ }, []);
166
174
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
167
175
  for (let i = 0; i < attempts; i++) {
176
+ if (!mountedRef.current)
177
+ return false;
168
178
  try {
169
179
  await a.play();
170
180
  return true;
@@ -485,13 +495,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
485
495
  setIsFetchingUrl(false);
486
496
  }
487
497
  };
498
+ /** 소스를 하나라도 얻었으면 true, 못 얻었으면 false, 이미 다른 불러오기가 돌면 null. */
488
499
  const fetchAndPrepare = async (playAfterLoad = false, showSpinner = true) => {
489
500
  const a = audioRef.current;
490
501
  const candidates = soundSrc ? (Array.isArray(soundSrc) ? soundSrc : [soundSrc]) : [];
491
502
  if (!candidates || candidates.length === 0 || !candidates.some(Boolean))
492
- return;
503
+ return false;
493
504
  if (fetchingRef.current)
494
- return;
505
+ return null;
495
506
  fetchingRef.current = true;
496
507
  if (showSpinner)
497
508
  setIsFetchingUrl(true);
@@ -591,6 +602,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
591
602
  setCurrentIndex(0);
592
603
  set구간표시(true);
593
604
  }
605
+ return finalSources.length > 0;
594
606
  }
595
607
  finally {
596
608
  fetchingRef.current = false;
@@ -705,7 +717,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
705
717
  catch (e) { }
706
718
  }
707
719
  setStateLocal("load");
708
- await fetchAndPrepare(true);
720
+ const 얻음 = await fetchAndPrepare(true);
721
+ /* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
722
+ 안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
723
+ if (얻음 === false && mountedRef.current) {
724
+ try {
725
+ setNoticeMessage(t('training.audioFetchError', '사운드 소스를 가져오지 못했습니다.'));
726
+ }
727
+ catch (_) { }
728
+ setStateLocal('active');
729
+ }
709
730
  return;
710
731
  }
711
732
  if (stateLocal === "load")
@@ -1009,10 +1030,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1009
1030
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1010
1031
  return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: STATUS_MESSAGES[fetchDebugStatus] })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1011
1032
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1012
- onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1033
+ onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1013
1034
  handleJumpTo(idx);
1014
1035
  }
1015
- catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1036
+ catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1016
1037
  /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
1017
1038
  mainVolume: mainVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
1018
1039
  }
@@ -113,5 +113,5 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
113
113
  때문이다 — 나머지 둘(choices 바뀜·페이지 전환)은 빈 Set 으로 되돌리는 자리라
114
114
  저 effect 의 `size === 0` 가드에 걸려 어차피 아무것도 안 했다. */
115
115
  const btnsClassName = `training-step__buttons${Array.isArray(choices) && choices.length > 3 ? ' training-step__buttons--wrap-3' : ''}`;
116
- return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
116
+ return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
117
117
  }
@@ -49,11 +49,11 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
49
49
  setSelectedAnswer(true);
50
50
  handleEndedLocal(true);
51
51
  }, [handleEndedLocal, currentState]);
52
- return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => {
52
+ return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
53
53
  if (currentState !== 'active')
54
54
  return;
55
55
  setSelectedAnswer(i);
56
56
  if (handleEndedLocal)
57
57
  handleEndedLocal(i);
58
- }, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
58
+ }, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
59
59
  }
@@ -440,6 +440,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
440
440
  }, 80);
441
441
  }
442
442
  catch (err) { }
443
- } : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
443
+ } : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
444
444
  }) }) }));
445
445
  }
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
70
70
  fontSize: `${fontSize}px`,
71
71
  lineHeight: `${lineHeight}px`,
72
72
  }), [fontSize, lineHeight]);
73
- return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
73
+ return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
74
74
  }
@@ -87,5 +87,5 @@ export default function TrainingStepTitle({ title, children, className = "", sta
87
87
  setPreviewContent(null);
88
88
  }
89
89
  }, [onPreviewTextBtn, previewText, showPreview]);
90
- return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
90
+ return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
91
91
  }
@@ -36,7 +36,12 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
36
36
  const audioRef = useRef(null);
37
37
  const isMobile = useMediaQuery('(max-width: 767px)');
38
38
  const [playing, setPlaying] = useState(false);
39
- const [audioUrl, setAudioUrl] = useState('');
39
+ /* 준비한 URL 은 **어느 낱말의 것인지와 함께** 둔다. 카드는 번호 key 로 재사용되는데(TrainingStepCard),
40
+ 전에는 낱말이 바뀌어도 URL 을 비우지 않아 새 낱말의 리졸버가 기다리는 동안·실패했을 때 누르면
41
+ **이전 낱말 소리**가 났다 — 변별 훈련에서 틀린 자극이다. 지금 낱말의 것이 아니면 없는 것으로 본다. */
42
+ const audioFor = `${typeof rawAudioSrc === 'string' ? rawAudioSrc : rawAudioSrc?.src ?? ''}\u0000${label}`;
43
+ const [prepared, setPrepared] = useState({ for: '', url: '' });
44
+ const audioUrl = prepared.for === audioFor ? prepared.url : '';
40
45
  // ── 이미지 ── (앞면 유지값 기준 — 플립 동안 이미지도 유지)
41
46
  const imageUrl = getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '';
42
47
  const { imgRef, loading: imgLoading, hasImage, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
@@ -48,14 +53,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
48
53
  if (rawAudioSrc) {
49
54
  if (typeof rawAudioSrc === 'string') {
50
55
  if (rawAudioSrc.startsWith('http') || rawAudioSrc.includes('/')) {
51
- setAudioUrl(rawAudioSrc);
56
+ setPrepared({ for: audioFor, url: rawAudioSrc });
52
57
  return;
53
58
  }
54
59
  }
55
60
  else if (typeof rawAudioSrc === 'object' && !Array.isArray(rawAudioSrc)) {
56
61
  const src = rawAudioSrc.src;
57
62
  if (src) {
58
- setAudioUrl(src);
63
+ setPrepared({ for: audioFor, url: src });
59
64
  return;
60
65
  }
61
66
  }
@@ -66,14 +71,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
66
71
  try {
67
72
  const url = await Promise.resolve(resolver(label));
68
73
  if (!cancelled && url)
69
- setAudioUrl(url);
74
+ setPrepared({ for: audioFor, url });
70
75
  }
71
76
  catch { }
72
77
  }
73
78
  };
74
79
  resolveAudio();
75
80
  return () => { cancelled = true; };
76
- }, [rawAudioSrc, label]);
81
+ }, [rawAudioSrc, label, audioFor]);
77
82
  // ── 언마운트 시 오디오 정리 ──
78
83
  useEffect(() => {
79
84
  return () => {
@@ -224,6 +229,6 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
224
229
  onSelect?.();
225
230
  }
226
231
  }
227
- : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
232
+ : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { htmlType: "button", size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
228
233
  });
229
234
  export default TrainingWordCard;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
4
4
  function derivePreviewMeta(trainingData, typeKey) {
5
5
  if (!trainingData)
@@ -46,7 +46,9 @@ function derivePreviewMeta(trainingData, typeKey) {
46
46
  export function useAudioPreview(trainingData, typeKey) {
47
47
  const [playing, setPlaying] = useState(false);
48
48
  const [playingIndex, setPlayingIndex] = useState(-1);
49
- const [previewUrls, setPreviewUrls] = useState([]);
49
+ /* 받은 주소는 **어느 코드 묶음의 것인지와 함께** 둔다. 코드가 없는 훈련으로 바뀌면 아래 효과가 일찍 빠져나가
50
+ 이전 훈련 주소가 남았고, 새 주소를 받는 동안에도 이전 주소로 미리듣기가 났다. 지금 묶음의 것만 쓴다. */
51
+ const [resolved, setResolved] = useState({ key: '', urls: [] });
50
52
  const previewAudioRef = useRef(null);
51
53
  const playingRef = useRef(false);
52
54
  const previewBtnRef = useRef(null);
@@ -55,6 +57,8 @@ export function useAudioPreview(trainingData, typeKey) {
55
57
  // 우리가 만든 게 아니므로 revoke 대상이 아니다
56
58
  const ownedBlobUrlsRef = useRef([]);
57
59
  const { previewCodes, previewBucket } = derivePreviewMeta(trainingData, typeKey);
60
+ const previewKey = `${previewCodes.join(',')}|${previewBucket ?? ''}`;
61
+ const previewUrls = useMemo(() => (resolved.key === previewKey ? resolved.urls : []), [resolved, previewKey]);
58
62
  // Resolve audio URLs upfront when training data changes
59
63
  useEffect(() => {
60
64
  if (!previewCodes.length)
@@ -98,7 +102,7 @@ export function useAudioPreview(trainingData, typeKey) {
98
102
  }
99
103
  catch { } });
100
104
  ownedBlobUrlsRef.current = newBlobUrls;
101
- setPreviewUrls(resolvedUrls);
105
+ setResolved({ key: previewKey, urls: resolvedUrls });
102
106
  }
103
107
  else {
104
108
  // 이 effect가 취소된 사이 새로 만든 blob은 ref에 등록되지 못하므로 즉시 해제
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
24
24
  // 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
25
25
  const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
26
26
  const noiseTypeLoadedRef = useRef(null);
27
+ /* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
28
+ 전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
29
+ fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
30
+ const pendingNoiseUrlRef = useRef(null);
27
31
  // volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
28
32
  const noiseSettingsRef = useRef(null);
29
33
  const noiseFadeRafRef = useRef(null);
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
43
47
  }, []);
44
48
  const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
45
49
  const noiseEl = noiseAudioRef.current;
46
- if (!noiseEl || !noiseEl.src)
50
+ if (!noiseEl)
51
+ return;
52
+ const pending = pendingNoiseUrlRef.current;
53
+ if (pending) {
54
+ pendingNoiseUrlRef.current = null;
55
+ try {
56
+ noiseEl.pause();
57
+ }
58
+ catch (e) { }
59
+ noiseEl.src = pending;
60
+ noiseEl.load();
61
+ }
62
+ if (!noiseEl.src)
47
63
  return;
48
64
  noiseFadeTokenRef.current += 1;
49
65
  cancelNoiseFade();
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
235
251
  if (url) {
236
252
  noiseTypeLoadedRef.current = ns.type;
237
253
  setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
254
+ pendingNoiseUrlRef.current = url;
238
255
  fadeOutNoise(150, () => {
256
+ // 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
257
+ if (pendingNoiseUrlRef.current !== url)
258
+ return;
259
+ pendingNoiseUrlRef.current = null;
239
260
  noiseEl.src = url;
240
261
  noiseEl.load();
241
262
  const mainAudio = audioRef.current;
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
13
13
  const ttsStartRef = useRef(null);
14
14
  const ttsEstimatedDurationRef = useRef(0);
15
15
  const ttsRafRef = useRef(null);
16
+ /* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
17
+ end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
18
+ 언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
+ const ttsTokenRef = useRef(0);
16
20
  useEffect(() => {
17
21
  // cleanup TTS resources on unmount
18
22
  return () => {
23
+ ttsTokenRef.current++;
19
24
  try {
20
25
  if (ttsRafRef.current) {
21
26
  cancelAnimationFrame(ttsRafRef.current);
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
52
57
  return;
53
58
  }
54
59
  const synth = window.speechSynthesis;
60
+ const myToken = ++ttsTokenRef.current;
55
61
  try {
56
62
  try {
57
63
  synth.cancel();
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
99
105
  catch (e) { }
100
106
  };
101
107
  utt.onend = () => {
108
+ if (myToken !== ttsTokenRef.current)
109
+ return;
102
110
  try {
103
111
  if (ttsRafRef.current) {
104
112
  cancelAnimationFrame(ttsRafRef.current);
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
149
157
  }
150
158
  }, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
151
159
  const stopTTS = useCallback(() => {
160
+ ttsTokenRef.current++;
152
161
  try {
153
162
  if (window.speechSynthesis) {
154
163
  try {
@@ -21,5 +21,5 @@ import './UpdateAlert.css';
21
21
  export default function UpdateAlert({ title, message, actionLabel, onAction, icon, onClose, className = '', }) {
22
22
  const { t } = useTranslation();
23
23
  const resolvedActionLabel = actionLabel ?? t('common.update', '업데이트');
24
- return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
24
+ return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
25
25
  }
@@ -45,7 +45,7 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
45
45
  const hasLabel = !!value && value !== '없음';
46
46
  return (_jsxs("span", { className: cx('phoneme-select-block__cell-jamo-part', value ? '' : 'phoneme-select-block__cell-jamo-part--empty', hasLabel ? '' : 'phoneme-select-block__cell-jamo-part--no-label'), children: [_jsx("span", { className: `phoneme-select-block__cell-jamo-char h1${value === '없음' ? ' phoneme-select-block__cell-jamo-char--excluded' : ''}`, children: value === '없음' ? 'x' : (value || placeholder) }), hasLabel && _jsx("span", { className: "phoneme-select-block__cell-jamo-label caption-2", children: displayLabel ?? label })] }, label));
47
47
  }), [sections, placeholder]);
48
- const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
48
+ const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
49
49
  return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
50
50
  ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
51
51
  const sel = selectedList[pairIdx * 2] ?? {};
@@ -66,6 +66,6 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
66
66
  // 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
67
67
  // "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
68
68
  const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
69
- return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
69
+ return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
70
70
  })(), afterContent] }));
71
71
  }