@ihabdevteam/core 0.136.0 → 0.138.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 (90) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +20 -5
  8. package/dist/packages/core/src/components/Breadcrumb.js +1 -0
  9. package/dist/packages/core/src/components/Button.js +10 -1
  10. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +3 -2
  11. package/dist/packages/core/src/components/Icon.js +4 -1
  12. package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
  13. package/dist/packages/core/src/components/LandingHeader.js +5 -1
  14. package/dist/packages/core/src/components/PageTitle.js +1 -0
  15. package/dist/packages/core/src/components/Pagination.js +1 -1
  16. package/dist/packages/core/src/components/Panel.css +3 -0
  17. package/dist/packages/core/src/components/Panel.d.ts +11 -1
  18. package/dist/packages/core/src/components/Panel.js +3 -3
  19. package/dist/packages/core/src/components/PanelRow.js +8 -1
  20. package/dist/packages/core/src/components/PassCard/PassCard.js +1 -1
  21. package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
  22. package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
  23. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
  24. package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
  25. package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
  26. package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
  27. package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
  28. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
  29. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
  30. package/dist/packages/core/src/components/Popup/Popup.css +10 -0
  31. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -0
  32. package/dist/packages/core/src/components/Popup/Popup.js +5 -4
  33. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -1
  34. package/dist/packages/core/src/components/ProgressIndicator.js +1 -0
  35. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
  36. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
  37. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
  38. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
  39. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
  40. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
  41. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
  42. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
  43. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
  44. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
  45. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
  46. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
  47. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
  48. package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
  49. package/dist/packages/core/src/components/Table.js +4 -1
  50. package/dist/packages/core/src/components/TextField.js +3 -1
  51. package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
  52. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +23 -26
  53. package/dist/packages/core/src/components/Training/TrainingBlock.js +20 -3
  54. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -4
  55. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -1
  56. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +20 -5
  57. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +60 -3
  58. package/dist/packages/core/src/components/Training/TrainingStepCard.js +16 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +2 -1
  60. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +26 -6
  61. package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
  62. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
  63. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +18 -3
  64. package/dist/packages/core/src/components/Training/TrainingWordCard.js +28 -5
  65. package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
  66. package/dist/packages/core/src/components/Training/currentSound.js +13 -0
  67. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +12 -1
  68. package/dist/packages/core/src/components/Training/useNoiseFade.js +7 -1
  69. package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
  70. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +3 -1
  71. package/dist/packages/core/src/components/WordTable.js +25 -7
  72. package/dist/packages/core/src/hooks/useEditableList.js +13 -5
  73. package/dist/packages/core/src/hooks/useFontScale.js +13 -4
  74. package/dist/packages/core/src/hooks/useIcon.js +3 -0
  75. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
  76. package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
  77. package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
  78. package/dist/packages/core/src/hooks/usePagedList.js +1 -0
  79. package/dist/packages/core/src/hooks/usePasswordChange.js +7 -0
  80. package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
  81. package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
  82. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
  83. package/dist/packages/core/src/i18n/locales/en.json +41 -4
  84. package/dist/packages/core/src/i18n/locales/ko.json +41 -4
  85. package/dist/packages/core/src/utils/errorMessage.js +7 -1
  86. package/dist/packages/core/src/utils/wordPairIndex.js +11 -3
  87. package/dist/packages/core/src/version.d.ts +1 -1
  88. package/dist/packages/core/src/version.js +1 -1
  89. package/dist/utils.cjs.js +2 -2
  90. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import Toast from '../Toast';
@@ -30,7 +30,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
30
30
  storageKey: 'textbox_fontScale',
31
31
  });
32
32
  const [noticeMessage, setNoticeMessage] = useState('');
33
- const handleActivate = useCallback(() => setLocalStatus("active"), []);
33
+ /* 초점을 가진 '문장 미리보기' 를 누르면 그 단추가 사라진다 — 펼친 상자의 첫 조작 단추로 초점을 옮긴다 */
34
+ const 상자Ref = useRef(null);
35
+ const 펼친뒤초점Ref = useRef(false);
36
+ const handleActivate = useCallback(() => {
37
+ 펼친뒤초점Ref.current = !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
38
+ setLocalStatus("active");
39
+ }, []);
34
40
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
35
41
  const showNotice = (msg) => {
36
42
  try {
@@ -73,5 +79,14 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
73
79
  fontSize: `${fontSize}px`,
74
80
  lineHeight: `${lineHeight}px`,
75
81
  }), [fontSize, lineHeight]);
76
- 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 })] }));
82
+ useEffect(() => {
83
+ if (displayStatus === 'disabled' || !펼친뒤초점Ref.current)
84
+ return;
85
+ 펼친뒤초점Ref.current = false;
86
+ const 지금 = document.activeElement;
87
+ if (지금 && 지금 !== document.body)
88
+ return;
89
+ 상자Ref.current?.querySelector('.training-text-box__buttons button')?.focus();
90
+ }, [displayStatus]);
91
+ return (_jsxs("div", { ref: 상자Ref, 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 })] }));
77
92
  }
@@ -9,7 +9,7 @@ import useMediaQuery from '../../hooks/useMediaQuery';
9
9
  import { getImageResolver } from '../../utils/imageConfig';
10
10
  import { useWordImage } from '../../hooks/useWordImage';
11
11
  import { getTTSUrlResolver, getTTSFallbackUrlResolver } from '../../utils/ttsConfig';
12
- import { claimSound } from './currentSound';
12
+ import { claimSound, stopSoundIfOwner } from './currentSound';
13
13
  import SymbolSvg from '../../logo/Symbol_speechear_black.svg';
14
14
  import { resolveAssetUrl } from '../../utils/assets';
15
15
  import './TrainingWordCard.css';
@@ -29,7 +29,8 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
29
29
  // 단어가 비면 뒷면으로 플립되는데, 그 애니메이션 동안 앞면이 '단어·발음 없이' 돌아가면 어색하다.
30
30
  // 플립 트리거(showBack)는 현재 값으로 두되, 앞면에 그릴 내용은 '마지막으로 채워졌던' 값을 유지한다.
31
31
  const [front, setFront] = useState({ label, subLabel, colloquialLabel, homonum: item.homonum });
32
- if (label && front.label !== label) {
32
+ // 낱말이 있으면 넷을 **통째로** 견준다 — 낱말만 보면 같은 낱말에 발음이 새로 오거나 동음이의어 번호가 바뀌어도 앞 값이 남았다
33
+ if (label && (front.label !== label || front.subLabel !== subLabel || front.colloquialLabel !== colloquialLabel || front.homonum !== item.homonum)) {
33
34
  setFront({ label, subLabel, colloquialLabel, homonum: item.homonum });
34
35
  }
35
36
  // ── 재생 상태 ──
@@ -80,8 +81,17 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
80
81
  return () => { cancelled = true; };
81
82
  }, [rawAudioSrc, label, audioFor]);
82
83
  // ── 언마운트 시 오디오 정리 ──
84
+ /* 떠날 때 **이 카드가 쥔 소리** 도 끊는다 — audio 만 멈춰, 음원 없는 낱말(speechSynthesis)을 듣는 중에 떠나면 말이
85
+ 계속 나왔다. 남이 이미 가져간 소리는 건드리지 않는다(stopSoundIfOwner). 떠난 뒤에는 기다리던 합성 주소가 와도 울리지 않는다. */
86
+ const mountedRef = useRef(true);
87
+ const 쥔소리Ref = useRef(null);
83
88
  useEffect(() => {
89
+ mountedRef.current = true;
84
90
  return () => {
91
+ mountedRef.current = false;
92
+ if (쥔소리Ref.current)
93
+ stopSoundIfOwner(쥔소리Ref.current);
94
+ 쥔소리Ref.current = null;
85
95
  try {
86
96
  audioRef.current?.pause();
87
97
  }
@@ -143,7 +153,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
143
153
  // 재생 우선순위: ① 정식 음원 URL → ② 앱이 등록한 런타임 합성 폴백(예: Gemini-TTS) →
144
154
  // ③ 브라우저 speechSynthesis. ②가 있으면 아직 음원이 없는 단어도 표준발음으로 들린다.
145
155
  const playAudio = useCallback(async () => {
146
- if (disabled)
156
+ if (disabled || !mountedRef.current)
147
157
  return;
148
158
  /* 이전 재생을 확실히 끊고 시작한다(겹침 방지).
149
159
 
@@ -151,7 +161,10 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
151
161
  것**이라, 옆 카드가 울리는 것은 못 멈췄다 — 변별 훈련에서 1번·2번을 잇달아 누르면
152
162
  둘이 함께 울렸다. speechSynthesis 만 전역이라 음원 없는 낱말에서는 멀쩡해 보였고,
153
163
  그래서 증상이 반만 나타났다. 스피커가 하나이므로 자물쇠도 전역에 둔다. */
154
- const release = claimSound(() => {
164
+ const 멈춤 = () => {
165
+ // 남(다른 카드·다음 누름)이 자리를 가져가며 부르면 이 카드는 더 이상 쥐고 있지 않다 — 기다리던 흐름이 이것을 보고 물러선다
166
+ if (쥔소리Ref.current === 멈춤)
167
+ 쥔소리Ref.current = null;
155
168
  try {
156
169
  audioRef.current?.pause();
157
170
  }
@@ -161,7 +174,9 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
161
174
  }
162
175
  catch { /* 미지원 환경 */ }
163
176
  setPlaying(false);
164
- });
177
+ };
178
+ const release = claimSound(멈춤);
179
+ 쥔소리Ref.current = 멈춤;
165
180
  try {
166
181
  window.speechSynthesis?.cancel();
167
182
  }
@@ -178,6 +193,10 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
178
193
  if (fallbackResolver && label) {
179
194
  try {
180
195
  const url = await Promise.resolve(fallbackResolver(label));
196
+ /* 기다리는 사이 떠났거나 **소리 자리를 남에게 넘겼으면**(다른 카드를 눌렀거나 같은 카드를 다시 눌렀으면) 끝낸다 —
197
+ 늦게 온 합성 주소로 틀어 두 소리가 겹쳤고, 같은 카드를 두 번 누르면 두 번 울려 onPlayDone 도 두 번 나갔다. */
198
+ if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
199
+ return;
181
200
  if (url) {
182
201
  setPlaying(true);
183
202
  if (await playUrl(url))
@@ -186,10 +205,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
186
205
  }
187
206
  catch { }
188
207
  }
208
+ if (!mountedRef.current || 쥔소리Ref.current !== 멈춤)
209
+ return;
189
210
  await playSpeechSynthesis();
190
211
  }
191
212
  finally {
192
213
  release(); // 다음 사람이 이미 가져갔으면 아무 일도 안 한다
214
+ if (쥔소리Ref.current === 멈춤)
215
+ 쥔소리Ref.current = null;
193
216
  }
194
217
  }, [disabled, audioUrl, label, playUrl, playSpeechSynthesis]);
195
218
  useImperativeHandle(ref, () => ({
@@ -22,5 +22,10 @@
22
22
  * 이미 다음 사람이 가져갔으면 그 사람 것을 지우면 안 된다.
23
23
  */
24
24
  export declare function claimSound(stop: () => void): () => void;
25
+ /**
26
+ * **내 소리가 아직 울리고 있을 때만** 멈추고 자리를 비운다 — 소리를 낸 화면이 떠날 때 쓴다.
27
+ * 이미 다음 사람이 가져갔으면 아무것도 안 한다(남의 소리를 끊지 않는다).
28
+ */
29
+ export declare function stopSoundIfOwner(stop: () => void): void;
25
30
  /** 지금 울리는 것을 멈춘다 — 화면을 떠날 때처럼, 새로 울릴 것이 없을 때 쓴다 */
26
31
  export declare function stopCurrentSound(): void;
@@ -34,6 +34,19 @@ export function claimSound(stop) {
34
34
  return () => { if (stopCurrent === stop)
35
35
  stopCurrent = null; };
36
36
  }
37
+ /**
38
+ * **내 소리가 아직 울리고 있을 때만** 멈추고 자리를 비운다 — 소리를 낸 화면이 떠날 때 쓴다.
39
+ * 이미 다음 사람이 가져갔으면 아무것도 안 한다(남의 소리를 끊지 않는다).
40
+ */
41
+ export function stopSoundIfOwner(stop) {
42
+ if (stopCurrent !== stop)
43
+ return;
44
+ stopCurrent = null;
45
+ try {
46
+ stop();
47
+ }
48
+ catch { /* 무시 */ }
49
+ }
37
50
  /** 지금 울리는 것을 멈춘다 — 화면을 떠날 때처럼, 새로 울릴 것이 없을 때 쓴다 */
38
51
  export function stopCurrentSound() {
39
52
  if (!stopCurrent)
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+ import i18n from "i18next";
2
3
  import { getTopicTypes } from "../../utils/categories";
3
4
  /** @public */
4
5
  export function normalizeCats(item) {
@@ -43,6 +44,12 @@ export function splitTrainingVariants(value) {
43
44
  }
44
45
  return [String(value)];
45
46
  }
47
+ const 이름 = (key, 기본) => {
48
+ if (!기본)
49
+ return 기본;
50
+ const s = typeof i18n?.t === "function" ? i18n.t(key, { defaultValue: 기본 }) : undefined;
51
+ return typeof s === "string" && s ? s : 기본;
52
+ };
46
53
  /** @public */
47
54
  export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
48
55
  const td = trainingData;
@@ -62,7 +69,11 @@ export function resolveTrainingPopupMeta(trainingData, depth, fallbackTitle) {
62
69
  wrapperStyle: category?.color
63
70
  ? { "--t-item-fg": category.color }
64
71
  : undefined,
65
- iconTooltip: [category?.label, subCategory?.label].filter(Boolean).join(" / ") || undefined,
72
+ // 카테고리 이름은 지금 언어로(topic.* · topic.sub.*, 사전에 없으면 등록된 이름)
73
+ iconTooltip: [
74
+ category ? 이름(`topic.${category.key}`, category.label) : undefined,
75
+ subCategory ? 이름(`topic.sub.${subCategory.key}`, subCategory.label) : undefined,
76
+ ].filter(Boolean).join(" / ") || undefined,
66
77
  };
67
78
  }
68
79
  /** @public */
@@ -67,7 +67,9 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
67
67
  // 볼륨은 setElementVolume 이 그래프면 GainNode, 아니면 요소 volume 에 건다(iOS 실기기는 요소 volume 이 안 먹는다).
68
68
  try {
69
69
  resumeAudioMeter();
70
- void routeElementAudio(noiseEl).then((an) => { try {
70
+ // 늦게 resume 돼도 버려진 소음 요소의 미터를 다시 등록하지 않는다
71
+ void routeElementAudio(noiseEl).then((an) => { if (noiseAudioRef.current !== noiseEl)
72
+ return; try {
71
73
  registerAnalyserRef.current?.('noise', an);
72
74
  }
73
75
  catch (e) { } });
@@ -168,6 +170,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
168
170
  }
169
171
  catch (e) { }
170
172
  noiseAudioRef.current = null;
173
+ /* 이 요소에 물린 파일 기록도 함께 걷는다 — 남겨 두면 다시 만든 요소(StrictMode 의 두 번 실행, <Activity> 숨김→보임)에
174
+ 설정 효과가 "이미 불러옴" 으로 보고 주소를 안 물려, 파형은 그려지는데 소음은 끝까지 안 났다. */
175
+ noiseTypeLoadedRef.current = null;
176
+ pendingNoiseUrlRef.current = null;
171
177
  };
172
178
  }, [cancelNoiseFade]);
173
179
  // 소음 감시(watchdog) — 메인이 재생 중이 아닌데 소음만 재생 중이면 강제로 정지한다.
@@ -17,6 +17,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
17
17
  end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
18
18
  언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
19
  const ttsTokenRef = useRef(0);
20
+ /** 이 훅이 말을 건 적이 있나 — 떠날 때 **제 말만** 끊는다(마운트만 한 단계가 다른 곳의 말을 끊던 것) */
21
+ const 말했나Ref = useRef(false);
20
22
  useEffect(() => {
21
23
  // cleanup TTS resources on unmount
22
24
  return () => {
@@ -26,11 +28,12 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
26
28
  cancelAnimationFrame(ttsRafRef.current);
27
29
  ttsRafRef.current = null;
28
30
  }
29
- if (window.speechSynthesis) {
31
+ if (말했나Ref.current && window.speechSynthesis) {
30
32
  try {
31
33
  window.speechSynthesis.cancel();
32
34
  }
33
35
  catch (e) { }
36
+ 말했나Ref.current = false;
34
37
  }
35
38
  }
36
39
  catch (e) { }
@@ -138,6 +141,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
138
141
  catch (e) { }
139
142
  };
140
143
  try {
144
+ 말했나Ref.current = true;
141
145
  synth.speak(utt);
142
146
  }
143
147
  catch (e) {
@@ -45,7 +45,9 @@ 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, { 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 }))] }));
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', '유의사항'),
49
+ // 좁은 화면에서는 아이콘만 남는다 — 이름을 늘 준다
50
+ ariaLabel: t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
49
51
  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
52
  ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
51
53
  const sel = selectedList[pairIdx * 2] ?? {};
@@ -31,7 +31,9 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
31
31
  const audioRef = useRef(null);
32
32
  const [scale, setScale] = useState(1);
33
33
  const [playing, setPlaying] = useState(false);
34
- const [audioUrl, setAudioUrl] = useState('');
34
+ /* 받은 주소에 **어느 낱말의 것인지** 를 함께 둔다 — 주소만 들고 있으면 같은 칸의 낱말이 바뀐 뒤 새 주소를 받는 동안(또는
35
+ 못 받으면) 누를 때 앞 낱말 소리가 났다(틀린 자극). 지금 낱말의 것일 때만 쓴다. */
36
+ const [받은소리, set받은소리] = useState({ for: '', url: '' });
35
37
  const [infoAnchor, setInfoAnchor] = useState(null);
36
38
  // 발음(subLabel)이 없으면 단어로 표준발음 조회(앱 주입 resolver)
37
39
  const pron = (subLabel && subLabel.length > 0) ? subLabel : (getPronunciationResolver()?.(label, homonum) ?? '');
@@ -43,17 +45,20 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
43
45
  if (!wordInfo)
44
46
  return null;
45
47
  const arr = [];
48
+ // 지금 언어로 — 템플릿 글자·약호 이름표가 한국어로 박혀 영어 화면에도 "난이도: 2등급 / 품사: 명사" 가 떴다
46
49
  if (wordInfo.grade)
47
- arr.push(`난이도: ${wordInfo.grade}등급`);
50
+ arr.push(t('wordTable.info.grade', { n: wordInfo.grade, defaultValue: '난이도: {{n}}등급' }));
48
51
  if (wordInfo.syllables)
49
- arr.push(`음절 수: ${wordInfo.syllables}음절`);
52
+ arr.push(t('wordTable.info.syllables', { n: wordInfo.syllables, defaultValue: '음절 수: {{n}}음절' }));
50
53
  // 축약 코드를 전체 표기로 — 복합값('명/관' → '명사/관형사')도 각각 펼친다(단어 관리 표와 동일 규칙).
51
54
  if (wordInfo.pos)
52
- arr.push(`품사: ${expandSlashLabel(wordInfo.pos, POS_LABEL)}`);
55
+ arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, 품사이름(t)), defaultValue: '품사: {{v}}' }));
53
56
  if (wordInfo.wordType)
54
- arr.push(`유형: ${expandSlashLabel(wordInfo.wordType, WORD_TYPE_LABEL)}`);
57
+ arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, 어종이름(t)), defaultValue: '유형: {{v}}' }));
55
58
  return arr.length ? arr : null;
56
- }, [wordInfo]);
59
+ }, [wordInfo, t]);
60
+ const 소리열쇠 = pron || label;
61
+ const audioUrl = 받은소리.for === 소리열쇠 ? 받은소리.url : '';
57
62
  useEffect(() => {
58
63
  const resolver = getTTSUrlResolver();
59
64
  const ttsText = pron || label;
@@ -61,7 +66,7 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
61
66
  return;
62
67
  let cancelled = false;
63
68
  Promise.resolve(resolver(ttsText)).then(url => { if (!cancelled && url)
64
- setAudioUrl(url); });
69
+ set받은소리({ for: ttsText, url }); });
65
70
  return () => { cancelled = true; };
66
71
  }, [pron, label]);
67
72
  // 컴포넌트 언마운트 시 발음 재생 중단 — audioRef는 <audio> JSX가 아니라 new Audio()로
@@ -128,6 +133,19 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
128
133
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
129
134
  ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => e.stopPropagation() }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
130
135
  }
136
+ // ── WordTable (표 디자인) ──────────────────────────────────────
137
+ /* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
138
+ const 품사열쇠 = {
139
+ '명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
140
+ '조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
141
+ };
142
+ const 어종열쇠 = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
143
+ function 품사이름(t) {
144
+ return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, 품사열쇠[k] ? t(`wordTable.pos.${품사열쇠[k]}`, { defaultValue: v }) : v]));
145
+ }
146
+ function 어종이름(t) {
147
+ return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, 어종열쇠[k] ? t(`wordSearch.filter.type.${어종열쇠[k]}`, { defaultValue: v }) : v]));
148
+ }
131
149
  /**
132
150
  * 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
133
151
  * 칸 그리드와 단어 셀을 그린다. 컨트롤(필터·저장·인쇄)은 포함하지 않는다.
@@ -40,12 +40,18 @@ export function useEditableList(emptyItem, initialList = []) {
40
40
  /* 갱신은 렌더가 아니라 효과에서 한다 — 렌더 중에 쓰면 커밋되지 않을 렌더의 값이 남는다.
41
41
  읽는 곳은 바로 아래 동기화 효과뿐이고, 그것이 뒤에 선언돼 있어 순서도 맞다.
42
42
  (효과는 선언 순서대로 돈다.) */
43
- const isEditingRef = useRef(isEditing);
44
- useEffect(() => { isEditingRef.current = isEditing; });
43
+ /* 편집이 **취소로** 끝나면 그 사이 건너뛴 목록을 받는다 — 전에는 initialList 가 바뀔 때만 돌아, 편집 중 들어온 서버 목록이
44
+ 취소한 뒤에도 끝내 안 보였다(방문 예정을 고치다 취소하면 그 사이 다른 방문이 빠진 채). commitEdit 으로 끝난 때는
45
+ 방금 커밋한 편집을 옛 목록으로 덮지 않도록 한 번 넘긴다(다음 목록이 오면 그때 받는다). */
46
+ const 커밋으로끝남Ref = useRef(false);
45
47
  const prevInitialRef = useRef(null);
46
48
  useEffect(() => {
47
- if (isEditingRef.current)
49
+ if (isEditing)
48
50
  return;
51
+ if (커밋으로끝남Ref.current) {
52
+ 커밋으로끝남Ref.current = false;
53
+ return;
54
+ }
49
55
  const prev = prevInitialRef.current;
50
56
  const same = prev !== null
51
57
  && prev.length === initialList.length
@@ -56,7 +62,7 @@ export function useEditableList(emptyItem, initialList = []) {
56
62
  setList(initialList);
57
63
  // 고른 항목도 새 목록의 같은 항목(id)으로 옮긴다 — 옛 참조로 남으면 지우기·수정 대상 찾기가 헛돈다
58
64
  setSelected(prev => (prev == null ? prev : initialList.find(v => isSameEditableItem(v, prev)) ?? prev));
59
- }, [initialList]);
65
+ }, [initialList, isEditing]);
60
66
  /* 편집 중이 아닐 때는 편집 폼을 고른 항목에 맞춘다 — 취소하거나(서버 거절로) 되돌려도 폼이 저장 안 된 값을 들고 있어,
61
67
  상세 화면에 거절된 값이 보이고 다시 '수정하기' 를 누르면 그 값에서 시작했다. */
62
68
  // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -72,7 +78,9 @@ export function useEditableList(emptyItem, initialList = []) {
72
78
  const commitEdit = useCallback((updater, updated) => {
73
79
  setList(updater);
74
80
  setSelected(updated);
75
- setIsEditing(false);
81
+ // 편집 중이었을 때만 표시한다(편집이 아닌데 표시하면 다음 목록 한 번을 헛되이 건너뛴다). 같은 값을 넣는 표시라 두 번 불려도 같다
82
+ setIsEditing(prev => { if (prev)
83
+ 커밋으로끝남Ref.current = true; return false; });
76
84
  }, []);
77
85
  const remove = useCallback((item) => {
78
86
  setList(prev => prev.filter(v => !isSameEditableItem(v, item)));
@@ -5,9 +5,9 @@ export default function useFontScale({ storageKey = 'fontScale',
5
5
  // 기본값을 중앙화: Training/Tooltip 텍스트 박스 공통값
6
6
  baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4, } = {}) {
7
7
  const clamp = (v) => Math.max(minScale, Math.min(maxScale, Number(v)));
8
- const [scale, setScaleRaw] = useState(() => {
8
+ const 읽기 = (key) => {
9
9
  try {
10
- const raw = localStorage.getItem(storageKey);
10
+ const raw = localStorage.getItem(key);
11
11
  const parsed = raw ? Number(raw) : 1;
12
12
  // 손상된/비숫자 저장값(Number(raw) === NaN)이 그대로 초기값이 되면 scale이 NaN으로
13
13
  // 고정되어 canIncrease/canDecrease 비교가 항상 false가 되고 fontSize/lineHeight도
@@ -17,9 +17,18 @@ baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4
17
17
  return Number.isFinite(parsed) ? clamp(parsed) : clamp(1);
18
18
  }
19
19
  catch (e) {
20
- return 1;
20
+ // 저장소를 못 읽어도 범위는 지킨다(minScale 이 1 보다 크면 1 은 범위 밖이다)
21
+ return clamp(1);
21
22
  }
22
- });
23
+ };
24
+ const [scale, setScaleRaw] = useState(() => 읽기(storageKey));
25
+ /* 열쇠가 바뀌면 **그 열쇠의 저장값** 으로 — 전에는 앞 열쇠의 값을 들고 있다가 아래 쓰기가 새 열쇠의 저장값을 덮었다.
26
+ 렌더 중에 맞추므로 쓰기 effect 는 새 값으로만 돈다. */
27
+ const [읽은열쇠, set읽은열쇠] = useState(storageKey);
28
+ if (읽은열쇠 !== storageKey) {
29
+ set읽은열쇠(storageKey);
30
+ setScaleRaw(읽기(storageKey));
31
+ }
23
32
  useEffect(() => {
24
33
  try {
25
34
  localStorage.setItem(storageKey, String(scale));
@@ -42,6 +42,9 @@ export default function useIcon(icon) {
42
42
  normalized = icon;
43
43
  }
44
44
  }
45
+ // 다른 파일로 바뀌면 앞 아이콘의 판정·SVG 를 걷는다 — 안 걷으면 새 파일을 받는 동안 앞 아이콘이 그대로 그려졌다
46
+ setMode(null);
47
+ setSvgText(null);
45
48
  setIconToUse(normalized);
46
49
  }, [icon]);
47
50
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -20,6 +20,9 @@ function toRawKey(icon) {
20
20
  return null;
21
21
  if (icon.includes('/assets/'))
22
22
  return null;
23
+ // 그림 파일(.png·.jpg 등)은 아이콘 이름이 아니다 — 이름으로 찾아 개발 모드에 헛 경고를 냈다
24
+ if (/\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i.test(lower))
25
+ return null;
23
26
  const baseFile = icon.split('/').pop() || '';
24
27
  // 주의: lucide 아이콘명 규약("trash-2", "edit-2", "link-2" 등)은 끝의 숫자가
25
28
  // "-2/-3" 변형 아이콘을 가리키는 유의미한 부분이라 잘라내면 안 됨(예: trash-2 ≠ trash).
@@ -1,3 +1,4 @@
1
+ import * as React from 'react';
1
2
  /**
2
3
  * 미디어쿼리 결과를 강제로 덮어쓰기 위한 컨텍스트.
3
4
  * 랜딩페이지 폰 목업처럼 데스크탑 뷰포트에서 모바일 화면을 재현해야 할 때,
@@ -12,6 +13,6 @@ export interface MediaQueryOverride {
12
13
  mobile?: boolean;
13
14
  }
14
15
  /** @public */
15
- export declare const MediaQueryOverrideContext: import("react").Context<MediaQueryOverride | null>;
16
+ export declare const MediaQueryOverrideContext: React.Context<MediaQueryOverride | null>;
16
17
  /** @public */
17
18
  export default function useMediaQuery(query: string): boolean;
@@ -1,19 +1,40 @@
1
1
  'use client';
2
- import { useEffect, useState, useContext, createContext } from 'react';
2
+ import * as React from 'react';
3
+ import { useCallback, useEffect, useState, useContext, createContext } from 'react';
3
4
  /** @public */
4
5
  export const MediaQueryOverrideContext = createContext(null);
5
6
  // '(max-width: 767px)' / '(max-width: 768px)' 같은 모바일 판정 쿼리인지 추정
6
7
  function isMobileQuery(query) {
7
8
  return /max-width:\s*7[0-9][0-9]px/.test(query);
8
9
  }
9
- /** @public */
10
- export default function useMediaQuery(query) {
11
- const override = useContext(MediaQueryOverrideContext);
12
- const getMatches = (q) => {
10
+ const getMatches = (q) => {
11
+ if (typeof window === 'undefined' || !window.matchMedia)
12
+ return false;
13
+ return window.matchMedia(q).matches;
14
+ };
15
+ /* React 18+ — 외부 저장소 구독으로 읽는다. **서버 스냅숏은 false** 라, 서버에서 그린 HTML 을 붙일 때(kscan·Next) 첫 렌더가
16
+ 서버와 같고 붙인 뒤 곧바로 실제 값으로 다시 그린다. 전에는 첫 렌더에서 matchMedia 를 읽어 모바일 기기에서 hydration
17
+ 불일치로 트리를 통째로 다시 만들었다. 서버 렌더가 아닌 첫 렌더는 그대로 실제 값이다. */
18
+ function useMatchesStore(query) {
19
+ const subscribe = useCallback((onChange) => {
13
20
  if (typeof window === 'undefined' || !window.matchMedia)
14
- return false;
15
- return window.matchMedia(q).matches;
16
- };
21
+ return () => { };
22
+ const mql = window.matchMedia(query);
23
+ if (mql.addEventListener)
24
+ mql.addEventListener('change', onChange);
25
+ else if (mql.addListener)
26
+ mql.addListener(onChange);
27
+ return () => {
28
+ if (mql.removeEventListener)
29
+ mql.removeEventListener('change', onChange);
30
+ else if (mql.removeListener)
31
+ mql.removeListener(onChange);
32
+ };
33
+ }, [query]);
34
+ return React.useSyncExternalStore(subscribe, () => getMatches(query), () => false);
35
+ }
36
+ /* React 16.8·17 — useSyncExternalStore 가 없다. 예전 방식 그대로. */
37
+ function useMatchesState(query) {
17
38
  const [matches, setMatches] = useState(getMatches(query));
18
39
  // query가 바뀌면 새 matchMedia 결과로 즉시 동기화(구독 시작 전 값이 stale할 수 있음) —
19
40
  // window.matchMedia(외부 시스템) 구독의 표준 패턴이다
@@ -37,6 +58,14 @@ export default function useMediaQuery(query) {
37
58
  };
38
59
  }, [query]);
39
60
  /* eslint-enable react-hooks/set-state-in-effect */
61
+ return matches;
62
+ }
63
+ // 모듈을 불러올 때 한 번 정한다 — 렌더마다 같은 훅을 부르므로 훅 순서 규칙을 어기지 않는다
64
+ const useMatches = typeof React.useSyncExternalStore === 'function' ? useMatchesStore : useMatchesState;
65
+ /** @public */
66
+ export default function useMediaQuery(query) {
67
+ const override = useContext(MediaQueryOverrideContext);
68
+ const matches = useMatches(query);
40
69
  // 오버라이드가 있고 모바일 판정 쿼리이면 강제값 사용
41
70
  if (override && typeof override.mobile === 'boolean' && isMobileQuery(query)) {
42
71
  return override.mobile;
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useMemo } from 'react';
2
3
  /**
3
4
  * 클라이언트 페이지네이션 — 목록·페이지크기·현재 페이지로 현재 쪽 항목·총 쪽수·보정된
@@ -15,6 +15,9 @@ export function usePasswordChange(onChangePassword) {
15
15
  const [pwMsg, setPwMsg] = useState(null);
16
16
  const [pwLoading, setPwLoading] = useState(false);
17
17
  const clearTimerRef = useRef(null);
18
+ /* 처리 중에는 다시 받지 않는다 — 두 번 누르면 변경이 두 번 나가, 먼저 온 성공 뒤에 늦게 온 "현재 비밀번호 틀림" 이
19
+ 남아 바뀌었는데 오류 문구가 떴다(말귀 계정 화면 단추는 로딩 중에도 눌린다) */
20
+ const 처리중Ref = useRef(false);
18
21
  useEffect(() => {
19
22
  return () => {
20
23
  if (clearTimerRef.current !== null)
@@ -26,6 +29,8 @@ export function usePasswordChange(onChangePassword) {
26
29
  setPwMsg(null);
27
30
  }, []);
28
31
  const handleChangePw = useCallback(async () => {
32
+ if (처리중Ref.current)
33
+ return;
29
34
  if (!pw.current) {
30
35
  setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
31
36
  return;
@@ -41,6 +46,7 @@ export function usePasswordChange(onChangePassword) {
41
46
  return;
42
47
  }
43
48
  setPwLoading(true);
49
+ 처리중Ref.current = true;
44
50
  // 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
45
51
  // reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
46
52
  let err;
@@ -51,6 +57,7 @@ export function usePasswordChange(onChangePassword) {
51
57
  err = getErrorMessage(e);
52
58
  }
53
59
  finally {
60
+ 처리중Ref.current = false;
54
61
  setPwLoading(false);
55
62
  }
56
63
  if (err) {
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useMemo } from 'react';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import { translateTrainingTypes } from '../utils/CategoryData';
@@ -1,3 +1,4 @@
1
+ 'use client';
1
2
  import { useState } from 'react';
2
3
  import { useEditableList, isSameEditableItem } from './useEditableList';
3
4
  /**
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
3
  import { CAPABILITY, generateRuntimeItems, precomputeCommonCombinations, } from '../utils/wordTrainingRuntime';
4
4
  import { valueToJamos, syllableToFilter, NO_CODA } from './useWordTrainingFilter';
5
5
  /** 선택된 단어 구성을 실제 매칭용 CompositionFilter로 변환. 선택 없으면 null.
@@ -36,6 +36,9 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
36
36
  // 배열인 이쪽만 빠져 있었다. effect 안에서 어차피 정렬해 문자열로 쓰고 있으니
37
37
  // 그 문자열을 의존성으로 삼는다. 내용이 같으면 다시 만들지 않는다.
38
38
  const gradeKey = gradeFilter.length > 0 ? gradeFilter.slice().sort().join(',') : '';
39
+ /* types 도 **내용** 으로 — 만드는 일은 어떤 유형이 있느냐(key)에만 달려 있다. 빠져 있어 늦게 채워진 목록([] → [A,B])으로는
40
+ 끝까지 아무것도 안 만들었다. 이름·색·부제는 아래에서 최신 types 로 덧입혀, 언어를 바꿔도 다시 뽑지 않고 이름만 따라간다. */
41
+ const typesKey = types.map(t => t.key).join('|');
39
42
  // 단어풀 갱신 시 사전 계산 (백그라운드 fire-and-forget)
40
43
  useEffect(() => {
41
44
  if (poolVersion > 0) {
@@ -100,7 +103,19 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
100
103
  }
101
104
  })();
102
105
  return () => { cancelled = true; };
103
- }, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey]); // eslint-disable-line react-hooks/exhaustive-deps
106
+ }, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey, typesKey]); // eslint-disable-line react-hooks/exhaustive-deps
104
107
  /* eslint-enable react-hooks/set-state-in-effect */
105
- return { perTypeData, generating };
108
+ // 이름·색·부제는 지금 types 로 — 만든 때의 label 을 들고 있어 언어를 바꿔도 단추 제목이 옛 언어로 남았다
109
+ // 부르는 쪽이 types 를 매번 새 배열로 줘도 결과 객체가 그대로이도록 **내용** 으로 기억한다
110
+ const typesLabelKey = JSON.stringify(types.map(t => [t.key, t.label, t.color, t.subTitle]));
111
+ const labeled = useMemo(() => {
112
+ const out = {};
113
+ for (const [key, v] of Object.entries(perTypeData)) {
114
+ const t = types.find(x => x.key === key);
115
+ out[key] = v && t ? { ...v, name: t.label, iconColor: t.color, subTitle: t.subTitle } : v;
116
+ }
117
+ return out;
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [perTypeData, typesLabelKey]);
120
+ return { perTypeData: labeled, generating };
106
121
  }