@ihabdevteam/core 0.127.0 → 0.130.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 (119) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/components.cjs.js +178 -178
  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-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +182 -182
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +276 -66
  10. package/dist/packages/core/src/components/AudioMeter.js +29 -15
  11. package/dist/packages/core/src/components/Avatar.js +6 -2
  12. package/dist/packages/core/src/components/Badge.d.ts +5 -1
  13. package/dist/packages/core/src/components/Badge.js +19 -5
  14. package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
  15. package/dist/packages/core/src/components/BulkBar.js +9 -2
  16. package/dist/packages/core/src/components/Calendar.js +1 -1
  17. package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
  18. package/dist/packages/core/src/components/ColorPicker.js +4 -2
  19. package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
  20. package/dist/packages/core/src/components/DatePicker.js +9 -4
  21. package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
  22. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
  23. package/dist/packages/core/src/components/EmptyState.js +3 -1
  24. package/dist/packages/core/src/components/ErrorState.js +3 -1
  25. package/dist/packages/core/src/components/FilterPanel.js +7 -0
  26. package/dist/packages/core/src/components/Header.css +8 -0
  27. package/dist/packages/core/src/components/Header.d.ts +6 -0
  28. package/dist/packages/core/src/components/Header.js +25 -1
  29. package/dist/packages/core/src/components/Icon.css +22 -0
  30. package/dist/packages/core/src/components/Icon.d.ts +7 -3
  31. package/dist/packages/core/src/components/Icon.js +7 -1
  32. package/dist/packages/core/src/components/IconGridMenu.css +4 -2
  33. package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
  34. package/dist/packages/core/src/components/IconGridMenu.js +6 -3
  35. package/dist/packages/core/src/components/LandingHeader.js +6 -3
  36. package/dist/packages/core/src/components/Loader.d.ts +1 -1
  37. package/dist/packages/core/src/components/Loader.js +7 -2
  38. package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
  39. package/dist/packages/core/src/components/NoticeBox.js +3 -1
  40. package/dist/packages/core/src/components/Pagination.d.ts +2 -2
  41. package/dist/packages/core/src/components/Pagination.js +2 -2
  42. package/dist/packages/core/src/components/PanelRow.js +5 -1
  43. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
  44. package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
  45. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
  46. package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
  47. package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
  48. package/dist/packages/core/src/components/Popup/Popup.css +1 -1
  49. package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
  50. package/dist/packages/core/src/components/Popup/Popup.js +21 -2
  51. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
  52. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
  53. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
  54. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
  55. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
  56. package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
  57. package/dist/packages/core/src/components/PromoBanner.js +23 -6
  58. package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
  59. package/dist/packages/core/src/components/RadioGroup.js +2 -2
  60. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
  61. package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
  62. package/dist/packages/core/src/components/Section.d.ts +7 -1
  63. package/dist/packages/core/src/components/Section.js +6 -2
  64. package/dist/packages/core/src/components/SideNav.css +16 -7
  65. package/dist/packages/core/src/components/SideNav.js +11 -1
  66. package/dist/packages/core/src/components/Table.d.ts +1 -1
  67. package/dist/packages/core/src/components/TextField.css +2 -1
  68. package/dist/packages/core/src/components/TextField.js +10 -2
  69. package/dist/packages/core/src/components/Toggle.js +4 -1
  70. package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
  71. package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
  72. package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
  73. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
  74. package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
  75. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
  76. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
  77. package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
  78. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
  79. package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
  80. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
  81. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  82. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
  83. package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
  84. package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
  85. package/dist/packages/core/src/components/WordTable.d.ts +1 -1
  86. package/dist/packages/core/src/components/WordTable.js +3 -1
  87. package/dist/packages/core/src/components.d.ts +3 -0
  88. package/dist/packages/core/src/components.js +5 -0
  89. package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
  90. package/dist/packages/core/src/hooks/useFontScale.js +3 -1
  91. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
  92. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
  93. package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
  94. package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
  95. package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
  96. package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
  97. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
  98. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
  99. package/dist/packages/core/src/i18n/locales/en.json +32 -5
  100. package/dist/packages/core/src/i18n/locales/ko.json +32 -5
  101. package/dist/packages/core/src/tokens.css +2 -1
  102. package/dist/packages/core/src/utils/CategoryData.js +2 -2
  103. package/dist/packages/core/src/utils/assets.js +7 -3
  104. package/dist/packages/core/src/utils/clipboard.js +16 -2
  105. package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
  106. package/dist/packages/core/src/utils/errorMessage.js +19 -2
  107. package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
  108. package/dist/packages/core/src/utils/getTrainingData.js +8 -1
  109. package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
  110. package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
  111. package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
  112. package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
  113. package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
  114. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
  115. package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
  116. package/dist/packages/core/src/version.d.ts +1 -1
  117. package/dist/packages/core/src/version.js +1 -1
  118. package/dist/utils.cjs.js +2 -2
  119. package/package.json +4 -2
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useState } from 'react';
3
+ /** form.direction 이 '좌'·'우' 가 아니면 null — 캐스팅만 하면 devices['엉뚱한값'] 같은 열쇠가 생긴다 */
4
+ const toSide = (d) => (d === '좌' || d === '우' ? d : null);
3
5
  /**
4
6
  * 보청기 기기 등록 / 이력 관리 상태를 캡슐화한 훅.
5
7
  */
@@ -16,14 +18,21 @@ export function useDeviceManagement() {
16
18
  // form.direction은 각 팝업(DeviceRegistrationPopup의 방향 드롭다운, DeviceListPopup의
17
19
  // 좌/우 탭)에서 사용자가 실제로 고른 값이다 — deviceDirection/listForSide는 팝업을 열 때의
18
20
  // 초기값일 뿐이라 팝업 내부에서 방향을 바꾸면 더 이상 실제 선택과 일치하지 않는다.
21
+ // 방향이 비었거나 '좌'·'우' 밖이면 **아무것도 바꾸지 않는다** — 예전에는 검사 없이 캐스팅해
22
+ // devices·deviceHistory 에 없는 열쇠가 생겼고 prev[side] 가 undefined 라 이력 펼치기에서 터졌다.
23
+ // 팝업은 닫지 않는다(사용자가 방향을 고르고 다시 제출할 수 있게).
19
24
  const handleDeviceSubmit = useCallback((form) => {
20
- const side = form.direction;
25
+ const side = toSide(form.direction);
26
+ if (!side)
27
+ return;
21
28
  setDevices(prev => ({ ...prev, [side]: form }));
22
29
  setDeviceHistory(prev => ({ ...prev, [side]: [form, ...prev[side]] }));
23
30
  setShowDevice(false);
24
31
  }, []);
25
32
  const handleSelectFromList = useCallback((form) => {
26
- const side = form.direction;
33
+ const side = toSide(form.direction);
34
+ if (!side)
35
+ return;
27
36
  setDevices(prev => ({ ...prev, [side]: form }));
28
37
  setShowDeviceList(false);
29
38
  }, []);
@@ -12,7 +12,9 @@ baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4
12
12
  // 손상된/비숫자 저장값(Number(raw) === NaN)이 그대로 초기값이 되면 scale이 NaN으로
13
13
  // 고정되어 canIncrease/canDecrease 비교가 항상 false가 되고 fontSize/lineHeight도
14
14
  // NaN이 된다 — 파싱 실패 시 기본값(1)으로 폴백한다.
15
- return Number.isFinite(parsed) ? parsed : 1;
15
+ // 범위 밖 저장값(다른 min/max 로 저장했거나 손으로 고친 값)도 조인다 — 안 조이면 첫 화면이
16
+ // 범위 밖 크기로 그려지고 canIncrease/canDecrease 가 둘 다 한쪽으로 쏠린다.
17
+ return Number.isFinite(parsed) ? clamp(parsed) : clamp(1);
16
18
  }
17
19
  catch (e) {
18
20
  return 1;
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * 아이콘 이름을 받아 lucide 컴포넌트를 돌려줍니다.
3
3
  *
4
- * 자주 쓰는 아이콘은 정적으로 번들되어 첫 렌더에, 서버 렌더에서도 바로
5
- * 나옵니다. 목록에 없는 이름만 아이콘 하나를 지연 로드하므로 그때만 잠깐
6
- * 비어 보입니다 — 어떤 아이콘이 목록에 있는지는
7
- * `utils/lucideIconSet.ts` 참조.
4
+ * 정적 목록(`utils/lucideIconSet.ts`)에 있는 아이콘은 첫 렌더에, 서버 렌더에서도
5
+ * 바로 나옵니다. 목록에 없는 이름은 `null` 을 돌려줍니다 — 받아 오지 않으며,
6
+ * 개발 모드에서 이름당 한 번 경고를 남깁니다. URL·에셋 경로도 `null` 입니다.
8
7
  *
9
8
  * @example
10
9
  * const HomeIcon = useLucideIcon('home')
@@ -36,10 +36,9 @@ const lazyCache = new Map();
36
36
  /**
37
37
  * 아이콘 이름을 받아 lucide 컴포넌트를 돌려줍니다.
38
38
  *
39
- * 자주 쓰는 아이콘은 정적으로 번들되어 첫 렌더에, 서버 렌더에서도 바로
40
- * 나옵니다. 목록에 없는 이름만 아이콘 하나를 지연 로드하므로 그때만 잠깐
41
- * 비어 보입니다 — 어떤 아이콘이 목록에 있는지는
42
- * `utils/lucideIconSet.ts` 참조.
39
+ * 정적 목록(`utils/lucideIconSet.ts`)에 있는 아이콘은 첫 렌더에, 서버 렌더에서도
40
+ * 바로 나옵니다. 목록에 없는 이름은 `null` 을 돌려줍니다 — 받아 오지 않으며,
41
+ * 개발 모드에서 이름당 한 번 경고를 남깁니다. URL·에셋 경로도 `null` 입니다.
43
42
  *
44
43
  * @example
45
44
  * const HomeIcon = useLucideIcon('home')
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { getErrorMessage } from '../utils/errorMessage';
3
5
  /**
4
6
  * 비밀번호 변경 폼 상태와 핸들러를 캡슐화한 훅.
5
7
  * showModal 상태는 컴포넌트가 직접 관리 (훅 밖에 두어야 모달 열기 트리거 제어 가능).
@@ -8,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
8
10
  * @public
9
11
  */
10
12
  export function usePasswordChange(onChangePassword) {
13
+ const { t } = useTranslation();
11
14
  const [pw, setPw] = useState({ current: '', next: '', confirm: '' });
12
15
  const [pwMsg, setPwMsg] = useState(null);
13
16
  const [pwLoading, setPwLoading] = useState(false);
@@ -24,31 +27,41 @@ export function usePasswordChange(onChangePassword) {
24
27
  }, []);
25
28
  const handleChangePw = useCallback(async () => {
26
29
  if (!pw.current) {
27
- setPwMsg({ type: 'error', text: '현재 비밀번호를 입력하세요.' });
30
+ setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
28
31
  return;
29
32
  }
30
33
  // 길이·복잡도 규칙은 onChangePassword(앱 측)가 관리자 보안 정책으로 검증한다 —
31
34
  // 여기서 고정 규칙을 두면 정책과 불일치가 생기므로 빈 값만 거른다.
32
35
  if (!pw.next) {
33
- setPwMsg({ type: 'error', text: '새 비밀번호를 입력하세요.' });
36
+ setPwMsg({ type: 'error', text: t('account.security.password.newRequired', '새 비밀번호를 입력하세요.') });
34
37
  return;
35
38
  }
36
39
  if (pw.next !== pw.confirm) {
37
- setPwMsg({ type: 'error', text: '새 비밀번호가 일치하지 않습니다.' });
40
+ setPwMsg({ type: 'error', text: t('account.security.password.mismatch', '새 비밀번호가 일치하지 않습니다.') });
38
41
  return;
39
42
  }
40
43
  setPwLoading(true);
41
- const err = onChangePassword ? await onChangePassword(pw.current, pw.next) : null;
42
- setPwLoading(false);
44
+ // 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
45
+ // reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
46
+ let err;
47
+ try {
48
+ err = onChangePassword ? await onChangePassword(pw.current, pw.next) : null;
49
+ }
50
+ catch (e) {
51
+ err = getErrorMessage(e);
52
+ }
53
+ finally {
54
+ setPwLoading(false);
55
+ }
43
56
  if (err) {
44
57
  setPwMsg({ type: 'error', text: err });
45
58
  return;
46
59
  }
47
- setPwMsg({ type: 'success', text: '비밀번호가 변경되었습니다.' });
60
+ setPwMsg({ type: 'success', text: t('account.security.password.changed', '비밀번호가 변경되었습니다.') });
48
61
  setPw({ current: '', next: '', confirm: '' });
49
62
  if (clearTimerRef.current !== null)
50
63
  clearTimeout(clearTimerRef.current);
51
64
  clearTimerRef.current = setTimeout(() => setPwMsg(null), 1500);
52
- }, [pw, onChangePassword]);
65
+ }, [pw, onChangePassword, t]);
53
66
  return { pw, setPw, pwMsg, setPwMsg, pwLoading, handleChangePw, reset };
54
67
  }
@@ -5,6 +5,8 @@ export type MergedVisit = {
5
5
  status: VisitAllStatus;
6
6
  };
7
7
  /**
8
+ * 개수 보기 옵션의 값 목록. 라벨은 한국어 고정이다 — 화면에는 훅이 돌려주는
9
+ * 번역된 `vapPageSizeOptions` 를 쓴다.
8
10
  * @public
9
11
  */
10
12
  export declare const VAP_PAGE_SIZE_OPTIONS: {
@@ -20,6 +22,11 @@ export declare function useVisitAllPanel(mergedVisits: MergedVisit[]): {
20
22
  setVapSearch: import("react").Dispatch<import("react").SetStateAction<string>>;
21
23
  vapPageSize: string;
22
24
  setVapPageSize: import("react").Dispatch<import("react").SetStateAction<string>>;
25
+ /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
26
+ vapPageSizeOptions: {
27
+ label: string;
28
+ value: string;
29
+ }[];
23
30
  vapStatus: string;
24
31
  setVapStatus: import("react").Dispatch<import("react").SetStateAction<string>>;
25
32
  vapPage: number;
@@ -1,6 +1,9 @@
1
1
  'use client';
2
2
  import { useMemo, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
3
4
  /**
5
+ * 개수 보기 옵션의 값 목록. 라벨은 한국어 고정이다 — 화면에는 훅이 돌려주는
6
+ * 번역된 `vapPageSizeOptions` 를 쓴다.
4
7
  * @public
5
8
  */
6
9
  export const VAP_PAGE_SIZE_OPTIONS = [
@@ -14,6 +17,7 @@ export const VAP_PAGE_SIZE_OPTIONS = [
14
17
  * `mergedVisits`(예정+완료+미방문)를 받아 파생 데이터를 계산.
15
18
  */
16
19
  export function useVisitAllPanel(mergedVisits) {
20
+ const { t } = useTranslation();
17
21
  const [vapSearch, setVapSearch] = useState('');
18
22
  const [vapPageSize, setVapPageSize] = useState('25');
19
23
  const [vapStatus, setVapStatus] = useState('all');
@@ -38,9 +42,13 @@ export function useVisitAllPanel(mergedVisits) {
38
42
  const vapPageItems = useMemo(() => {
39
43
  return vapFiltered.slice((vapPageClamped - 1) * Number(vapPageSize), vapPageClamped * Number(vapPageSize));
40
44
  }, [vapFiltered, vapPageClamped, vapPageSize]);
45
+ // 내보낸 상수는 모양·라벨을 그대로 둔다(소비자가 값 목록으로 쓴다). 화면용 라벨만 여기서 번역한다.
46
+ const vapPageSizeOptions = useMemo(() => VAP_PAGE_SIZE_OPTIONS.map(o => ({ ...o, label: t('common.pageSizeLabel', { n: o.value, defaultValue: o.label }) })), [t]);
41
47
  return {
42
48
  vapSearch, setVapSearch,
43
49
  vapPageSize, setVapPageSize,
50
+ /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
51
+ vapPageSizeOptions,
44
52
  vapStatus, setVapStatus,
45
53
  vapPage: vapPageClamped, setVapPage,
46
54
  vapFiltered,
@@ -44,6 +44,9 @@ export function useWordMeanings(rows, fetchMeanings) {
44
44
  memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
45
45
  }
46
46
  setFetched(v => v + 1);
47
+ }).catch(() => {
48
+ // 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
49
+ // 뜻은 있으면 좋은 것이라 빈 결과와 같게 다룬다. 캐시에 안 넣었으니 다음에 다시 받는다.
47
50
  });
48
51
  return () => { cancelled = true; };
49
52
  }, [wantedKey, fetchMeanings]);
@@ -5,11 +5,16 @@ export type { WordComposition } from './wordTrainingFilterModel';
5
5
  /** 설정 팝업 취소 시 되돌릴 필터 스냅샷 */
6
6
  export interface WordFilterSnapshot {
7
7
  composition: WordComposition;
8
+ /** 뜰 때의 대립 갯수. composition 에서 파생되므로 되돌릴 때는 읽지 않는다. */
8
9
  contrastCount: number;
9
10
  excludeNoImage: boolean;
10
11
  sameStructureOnly: boolean;
11
12
  phonemeView: 'char' | 'pron' | 'direct';
12
13
  gradeFilter: string[];
14
+ /** 품사 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
15
+ posFilter?: string[];
16
+ /** 어종 필터. 없으면(예전에 뜬 스냅샷) 되돌릴 때 지금 값을 그대로 둔다. */
17
+ wordTypeFilter?: string[];
13
18
  }
14
19
  /**
15
20
  * 그룹이 노출할 설정 항목. 그룹에 속한 훈련 타입들의 CAPABILITY 합집합으로 결정.
@@ -25,6 +25,11 @@ function loadSavedFilter(groupKey) {
25
25
  catch { }
26
26
  return null;
27
27
  }
28
+ /** 저장값에서 보기 모드 복원 — 마운트 때와 groupKey 가 바뀌어 다시 읽을 때 같은 규칙을 쓴다 */
29
+ function readPhonemeView(saved) {
30
+ const v = saved?.phonemeView;
31
+ return v === 'pron' ? 'pron' : v === 'direct' ? 'direct' : 'char';
32
+ }
28
33
  /** 저장값에서 composition 복원 (레거시 choseong → cho 마이그레이션) */
29
34
  function readComposition(saved) {
30
35
  const s = saved;
@@ -52,16 +57,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
52
57
  // resetOnMount(단어훈련 만들기 등): 진입 시 항상 기본값에서 시작하도록 저장값을 불러오지 않는다.
53
58
  // localStorage 읽기/파싱은 마운트 시 1회만 (lazy init)
54
59
  const [saved] = useState(() => opts?.resetOnMount ? null : loadSavedFilter(groupKey));
55
- // 1~4개는 정확히 N개 차이, 5는 "5개 이상"(≥5) 버킷 — 런타임에서 threshold로 처리.
56
- // saved.contrastCount는 localStorage에 저장된 이전 세션의 값 — 과거 포맷이나 수동 편집으로
57
- // 숫자가 아닌 값이 들어있으면 Number.isFinite로 걸러 NaN이 이후 계산(비교·최소음절수 등)에
58
- // 조용히 전파되는 것을 막는다.
59
- const rawContrastCount = saved?.contrastCount;
60
- const savedContrastCount = typeof rawContrastCount === 'number' && Number.isFinite(rawContrastCount) ? rawContrastCount : 1;
61
- const [contrastCount, setContrastCount] = useState(Math.min(5, Math.max(1, savedContrastCount)));
62
60
  // 훈련 음소 그룹 보기 모드 — 'char'(글자 선택) | 'pron'(발음 선택).
63
61
  // composition 값이 모드별(글자=개별 자모, 발음=병합/대표음)로 저장되므로, 모드도 함께 persist 해야 재로드 시 일치한다.
64
- const [phonemeView, setPhonemeView] = useState(saved?.phonemeView === 'pron' ? 'pron' : saved?.phonemeView === 'direct' ? 'direct' : 'char');
62
+ const [phonemeView, setPhonemeView] = useState(readPhonemeView(saved));
65
63
  const [composition, setComposition] = useState(() => readComposition(saved));
66
64
  // 타입별 시드 composition — 한 음절 특정(requireSyllable)은 '가'(ㄱ+ㅏ+제외), 그 외는 중립.
67
65
  // 음절 수 고정(fixedSyllable)·종성 제외 고정(fixedNoCoda)이 있으면 그 값을 반영한다.
@@ -116,7 +114,6 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
116
114
  setGradeFilter([]);
117
115
  setPosFilter([]);
118
116
  setWordTypeFilter([]);
119
- setContrastCount(1);
120
117
  // seedComposition은 caps에서 파생 — resetKey와 함께 갱신되므로 deps는 resetKey만 둔다.
121
118
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
119
  }, [opts?.resetKey]);
@@ -131,13 +128,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
131
128
  // 훈련할 음소를 고른 슬롯(초성/모음/종성) 수 = 정답↔오답이 달라야 하는 음소의 최소 개수.
132
129
  // 예: 3슬롯 모두 선택하면 최소 3개 음소가 달라야 하므로 '다른 음소의 총 갯수'는 3부터 가능(1·2개 비활성).
133
130
  const minDiffCount = Math.max(1, [composition.cho, composition.jung, composition.jong].filter(Boolean).length);
134
- // 최소 개수 미만이 선택돼 있으면 최소값으로 자동 상향 (비활성 옵션이 선택된 채 남지 않도록)
135
- /* eslint-disable react-hooks/set-state-in-effect */
136
- useEffect(() => {
137
- if (contrastCount < minDiffCount)
138
- setContrastCount(minDiffCount);
139
- }, [minDiffCount, contrastCount]);
140
- /* eslint-enable react-hooks/set-state-in-effect */
131
+ // 대립 갯수 = 고른 칸 수(파생값). 1~4개는 정확히 N개 차이, 5는 "5개 이상"(≥5) 버킷.
132
+ // 예전에는 상태로 두고 "고른 칸 수보다 작으면 올린다"만 했다 — 고르는 UI('다른 음소의 총 갯수')가
133
+ // 걷힌 뒤로는 내릴 길이 없어, 칸 선택을 풀어도 높은 채로 남아 오답 조건이 틀어지고 그대로
134
+ // 저장됐다. 저장된 contrastCount 는 이제 읽지 않는다(저장 형식은 그대로 두어 옛 판과 호환).
135
+ const contrastCount = minDiffCount;
141
136
  // groupKey 는 마운트 때 한 번만 읽힌다(saved 가 lazy init). 그런데 저장 effect 는
142
137
  // groupKey 를 의존성에 두고 **지금 든 값**을 새 열쇠에 쓴다. 그래서 살아 있는 훅에서
143
138
  // groupKey 를 바꾸면 앞 그룹의 값이 뒤 그룹 저장소를 덮어썼다 — 실측으로 B 에 있던
@@ -154,12 +149,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
154
149
  return;
155
150
  if (!opts?.resetOnMount) {
156
151
  const s = loadSavedFilter(groupKey);
157
- const n = typeof s?.contrastCount === 'number' && Number.isFinite(s.contrastCount) ? s.contrastCount : 1;
158
- setContrastCount(Math.min(5, Math.max(1, n)));
159
152
  setComposition(s?.composition ?? seedComposition());
160
153
  setExcludeNoImage(s?.excludeNoImage ?? true);
161
154
  setSameStructureOnly(s?.sameStructureOnly ?? true);
162
- setPhonemeView(s?.phonemeView === 'pron' ? 'pron' : 'char');
155
+ // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
156
+ setPhonemeView(readPhonemeView(s));
163
157
  setGradeFilter(Array.isArray(s?.gradeFilter) ? s.gradeFilter : []);
164
158
  setPosFilter(Array.isArray(s?.posFilter) ? s.posFilter : []);
165
159
  setWordTypeFilter(Array.isArray(s?.wordTypeFilter) ? s.wordTypeFilter : []);
@@ -294,8 +288,6 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
294
288
  }), [viable]);
295
289
  // 번역된 레이블 — 표시용. 내부 로직은 여전히 한국어 상수 키로 동작
296
290
  const LABELS = useMemo(() => ({
297
- // 다른 음소 갯수 — 정답과 오답이 서로 다른 음소(자모)의 총 개수 (위치 구분 없음)
298
- diffPhonemeCount: t('training.contrast.diffPhonemeCount', { defaultValue: '다른 음소의 총 갯수' }),
299
291
  // 정답 단어 구성 — 음절수/초성/모음/종성 (정답 단어에만 적용)
300
292
  composition: t('wordPairs.composition', '정답 단어 구성'),
301
293
  syllableCount: t('wordPairs.syllableCount', '음절 수'),
@@ -433,10 +425,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
433
425
  }
434
426
  return sections;
435
427
  }, [caps.choseong, caps.partOfSpeech, caps.wordType, caps.requireSyllable, caps.fixedSyllable, caps.fixedNoCoda, caps.requireCoda, minSyllables, partViable, viable, phonemeView, syllableViable, LABELS, t, infoAction, selectInfoItems, attrKeys]);
428
+ // '다른 음소의 총 갯수' 섹션은 걷혔다 — 그 선택값·선택 처리 갈래도 함께 뺀다(대립 갯수는 파생값).
436
429
  const filterSelected = {
437
- ...(caps.difficulty ? {
438
- [LABELS.diffPhonemeCount]: String(contrastCount),
439
- } : {}),
440
430
  ...((caps.choseong || caps.fixedSyllable != null) && composition.syllable != null ? { [LABELS.syllableCount]: syllableValue(composition.syllable) } : {}),
441
431
  ...(caps.choseong && composition.cho ? { [LABELS.trainOnset]: composition.cho } : {}),
442
432
  ...(caps.choseong && composition.jung ? { [LABELS.trainVowel]: composition.jung } : {}),
@@ -454,12 +444,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
454
444
  ...(caps.wordType ? { [attrKeys.wordType]: wordTypeFilter } : {}),
455
445
  };
456
446
  const handleFilterSelect = useCallback((value, sectionLabel) => {
457
- if (sectionLabel === LABELS.diffPhonemeCount) {
458
- const n = Number(value);
459
- if (n >= 1)
460
- setContrastCount(n);
461
- }
462
- else if (sectionLabel === LABELS.exclude) {
447
+ if (sectionLabel === LABELS.exclude) {
463
448
  // 다중 토글 — 클릭한 항목만 on/off
464
449
  if (value === LABELS.excludeImage) {
465
450
  setExcludeNoImage(v => !v);
@@ -540,16 +525,21 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
540
525
  setPhonemeView(next);
541
526
  }, [phonemeView]);
542
527
  // 설정 팝업 취소용 — 현재 필터 상태 스냅샷 / 스냅샷으로 복원
528
+ // 품사·어종도 함께 뜬다 — 빠져 있어서 설정 팝업에서 취소해도 그 둘만 되돌아가지 않았다.
529
+ // contrastCount 는 composition 에서 파생되므로 되돌릴 때 따로 세우지 않는다.
543
530
  const getFilterSnapshot = useCallback(() => ({
544
- composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter,
545
- }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter]);
531
+ composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter,
532
+ }), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter]);
546
533
  const restoreFilterSnapshot = useCallback((s) => {
547
534
  setComposition(s.composition);
548
- setContrastCount(s.contrastCount);
549
535
  setExcludeNoImage(s.excludeNoImage);
550
536
  setSameStructureOnly(s.sameStructureOnly ?? true);
551
537
  setPhonemeView(s.phonemeView);
552
538
  setGradeFilter(Array.isArray(s.gradeFilter) ? s.gradeFilter : []);
539
+ if (Array.isArray(s.posFilter))
540
+ setPosFilter(s.posFilter);
541
+ if (Array.isArray(s.wordTypeFilter))
542
+ setWordTypeFilter(s.wordTypeFilter);
553
543
  }, []);
554
544
  return {
555
545
  difficulty,
@@ -82,7 +82,8 @@
82
82
  "w": "w-glide",
83
83
  "velar": "ɰ-glide"
84
84
  }
85
- }
85
+ },
86
+ "unknownError": "Unknown error"
86
87
  },
87
88
  "calendar": {
88
89
  "rangeSelect": "Select period",
@@ -281,7 +282,11 @@
281
282
  "security": {
282
283
  "title": "Security",
283
284
  "password": {
284
- "label": "Password"
285
+ "label": "Password",
286
+ "currentRequired": "Enter your current password.",
287
+ "newRequired": "Enter a new password.",
288
+ "mismatch": "The new passwords do not match.",
289
+ "changed": "Your password has been changed."
285
290
  },
286
291
  "twoFactor": {
287
292
  "label": "Two-Factor Authentication",
@@ -310,7 +315,6 @@
310
315
  "label": "Contrast",
311
316
  "type": "Contrast Type",
312
317
  "count": "Contrast Count",
313
- "diffPhonemeCount": "Total differing phonemes",
314
318
  "n": "{{n}}"
315
319
  },
316
320
  "filter": {
@@ -403,7 +407,8 @@
403
407
  "notJudged": "Not judged",
404
408
  "replay": "Replay",
405
409
  "done": "Done",
406
- "viewResult": "View Results"
410
+ "viewResult": "View Results",
411
+ "noContent": "(No content)"
407
412
  },
408
413
  "up": "Move up",
409
414
  "down": "Move down",
@@ -502,6 +507,11 @@
502
507
  "copy": "Copy Link",
503
508
  "inactive": "Inactive",
504
509
  "title": "Homework Progress"
510
+ },
511
+ "duration": {
512
+ "minSec": "{{m}}m {{s}}s",
513
+ "min": "{{m}}m",
514
+ "sec": "{{s}}s"
505
515
  }
506
516
  },
507
517
  "payment": {
@@ -672,7 +682,9 @@
672
682
  "tab": {
673
683
  "cards": "Word Table Cards",
674
684
  "tables": "Word Tables"
675
- }
685
+ },
686
+ "publishedBadge": "Published",
687
+ "sharedBadge": "Shared"
676
688
  },
677
689
  "keyWords": {
678
690
  "title": "Key Words",
@@ -1362,5 +1374,20 @@
1362
1374
  "velar": "Velar",
1363
1375
  "glottal": "Glottal"
1364
1376
  }
1377
+ },
1378
+ "bulkBar": {
1379
+ "clearSelection": "Clear selection"
1380
+ },
1381
+ "debug": {
1382
+ "showResult": "Show {{type}} result"
1383
+ },
1384
+ "badge": {
1385
+ "selected": "Selected",
1386
+ "remove": "Remove",
1387
+ "exclude": "Excluded",
1388
+ "nthSelected": "Selection {{n}}"
1389
+ },
1390
+ "loader": {
1391
+ "loading": "Loading"
1365
1392
  }
1366
1393
  }
@@ -80,7 +80,8 @@
80
80
  "w": "w활음",
81
81
  "velar": "ɰ활음"
82
82
  }
83
- }
83
+ },
84
+ "unknownError": "알 수 없는 오류"
84
85
  },
85
86
  "calendar": {
86
87
  "rangeSelect": "기간 선택",
@@ -279,7 +280,11 @@
279
280
  "security": {
280
281
  "title": "보안",
281
282
  "password": {
282
- "label": "비밀번호"
283
+ "label": "비밀번호",
284
+ "currentRequired": "현재 비밀번호를 입력하세요.",
285
+ "newRequired": "새 비밀번호를 입력하세요.",
286
+ "mismatch": "새 비밀번호가 일치하지 않습니다.",
287
+ "changed": "비밀번호가 변경되었습니다."
283
288
  },
284
289
  "twoFactor": {
285
290
  "label": "2단계 인증",
@@ -308,7 +313,6 @@
308
313
  "label": "대립",
309
314
  "type": "대립 종류",
310
315
  "count": "대립 갯수",
311
- "diffPhonemeCount": "다른 음소의 총 갯수",
312
316
  "n": "{{n}}개"
313
317
  },
314
318
  "filter": {
@@ -401,7 +405,8 @@
401
405
  "notJudged": "미판정",
402
406
  "replay": "다시하기",
403
407
  "done": "완료",
404
- "viewResult": "결과 확인"
408
+ "viewResult": "결과 확인",
409
+ "noContent": "(내용없음)"
405
410
  },
406
411
  "up": "위로",
407
412
  "down": "아래로",
@@ -500,6 +505,11 @@
500
505
  "completedFmt": "완료 {{done}}/{{total}} · {{duration}}"
501
506
  },
502
507
  "visitSuffix": "의 방문"
508
+ },
509
+ "duration": {
510
+ "minSec": "{{m}}분 {{s}}초",
511
+ "min": "{{m}}분",
512
+ "sec": "{{s}}초"
503
513
  }
504
514
  },
505
515
  "payment": {
@@ -670,7 +680,9 @@
670
680
  "publishStore": "말귀자료실에 올리기",
671
681
  "unpublishStore": "말귀자료실에서 내리기"
672
682
  },
673
- "newBadge": "새로 만든 표"
683
+ "newBadge": "새로 만든 표",
684
+ "publishedBadge": "발행됨",
685
+ "sharedBadge": "공유 중"
674
686
  },
675
687
  "keyWords": {
676
688
  "title": "주요 단어",
@@ -1360,5 +1372,20 @@
1360
1372
  "velar": "연구개음",
1361
1373
  "glottal": "성문음"
1362
1374
  }
1375
+ },
1376
+ "bulkBar": {
1377
+ "clearSelection": "선택 해제"
1378
+ },
1379
+ "debug": {
1380
+ "showResult": "{{type}} 결과보기"
1381
+ },
1382
+ "badge": {
1383
+ "selected": "선택됨",
1384
+ "remove": "제거",
1385
+ "exclude": "제외",
1386
+ "nthSelected": "{{n}}번째 선택"
1387
+ },
1388
+ "loader": {
1389
+ "loading": "불러오는 중…"
1363
1390
  }
1364
1391
  }
@@ -269,7 +269,8 @@
269
269
  --text-h0-size: 32px; --text-h0-line: 44px; --text-h0-spacing: -0.75px;
270
270
  --text-h1-size: 28px; --text-h1-line: 40px; --text-h1-spacing: -0.4px;
271
271
  --text-h2-size: 22px; --text-h2-line: 32px; --text-h2-spacing: -0.2px;
272
- --text-h3-size: 18px; --text-h3-line: 28px;
272
+ /* h3 는 태블릿에서 줄이지 않는다 — 데스크톱 20px/30px 그대로(2026-09-14 사용자 결정).
273
+ 모바일(≤767px)의 17px 은 따로 둔다. */
273
274
  --text-h4-size: 16px; --text-h4-line: 24px;
274
275
  --text-h5-size: 14px; --text-h5-line: 21px; /* 15→14: h5↔body-2 충돌 해소 */
275
276
  --text-body-1-size: 17px; --text-body-1-line: 27px; /* 16→17: h4(16px)와 분리 */
@@ -7,8 +7,8 @@ export const TRAINING_TYPES = [
7
7
  ];
8
8
  /** @public */
9
9
  export const TOPIC_TYPES = [
10
- { key: 'FOOD', label: '음식', icon: 'salad', color: '#FF8A65', subs: [{ key: 'TRKRFD', label: ' 한국 전통음식' }, { key: 'MDKRFD', label: '한국 현대음식' }, { key: 'WRLDFD', label: '세계의 음식' }, { key: 'MTRFRT', label: '식재료와 과일' }, { key: 'GEFDKL', label: '음식 상식' }] },
11
- { key: 'HLTH', label: '건강', icon: 'heart', color: '#66BB6A', subs: [{ key: 'HTETHB', label: '건강한 식습관' }, { key: 'DISILL', label: '병과 질환' }, { key: 'EXCISE', label: '운동' }, { key: 'GEHTKL', label: '건상상식' }] },
10
+ { key: 'FOOD', label: '음식', icon: 'salad', color: '#FF8A65', subs: [{ key: 'TRKRFD', label: '한국 전통음식' }, { key: 'MDKRFD', label: '한국 현대음식' }, { key: 'WRLDFD', label: '세계의 음식' }, { key: 'MTRFRT', label: '식재료와 과일' }, { key: 'GEFDKL', label: '음식 상식' }] },
11
+ { key: 'HLTH', label: '건강', icon: 'heart', color: '#66BB6A', subs: [{ key: 'HTETHB', label: '건강한 식습관' }, { key: 'DISILL', label: '병과 질환' }, { key: 'EXCISE', label: '운동' }, { key: 'GEHTKL', label: '건강상식' }] },
12
12
  { key: 'WETH', label: '날씨', icon: 'sun-medium', color: '#42A5F5', subs: [{ key: 'GESSKL', label: '계절상식' }, { key: 'SNWRAN', label: '눈과 비' }, { key: 'WTHFCT', label: '일기예보' }, { key: 'GEWTKL', label: '날씨상식' }] },
13
13
  { key: 'PRSN', label: '인물', icon: 'user-round', desc: '소음 환경 속 문장 이해 훈련', color: '#AB47BC', subs: [{ key: 'JODYPS', label: '조선시대' }, { key: 'MDKRPS', label: '한국 근현대' }, { key: 'THKDPS', label: '삼국시대' }, { key: 'WRLART', label: '세계의 예술가' }, { key: 'WRLPSN', label: '세계의 인물' }] },
14
14
  { key: 'KRCT', label: '한국문화', icon: 'bow-arrow', desc: '소음 환경 속 문장 이해 훈련', color: '#EC407A', subs: [{ key: 'KRLANG', label: '우리말' }, { key: 'TRPLCT', label: '전통놀이' }, { key: 'HERITG', label: '문화재' }, { key: 'EVTFES', label: '행사와 축제' }, { key: 'GECTKL', label: '상식' }] },
@@ -1,5 +1,8 @@
1
1
  const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i;
2
- let configuredAssetBaseUrl = '';
2
+ // globalThis 싱글톤: 다른 설정(storage·ttsConfig·imageConfig)과 같은 방식.
3
+ // 모듈 변수로 두면 CJS 묶음이 여럿일 때(utils.cjs 에서 설정, components.cjs 에서 읽기)
4
+ // 묶음마다 한 벌씩 생겨 설정이 안 보였다.
5
+ const ASSET_GLOBAL_KEY = '__ihab_core_asset_base_url__';
3
6
  function normalizeAssetBaseUrl(baseUrl) {
4
7
  const raw = String(baseUrl || '').trim();
5
8
  if (!raw)
@@ -30,11 +33,12 @@ function normalizeAssetBaseUrl(baseUrl) {
30
33
  * @public
31
34
  */
32
35
  export function setAssetBaseUrl(baseUrl) {
33
- configuredAssetBaseUrl = normalizeAssetBaseUrl(baseUrl);
36
+ ;
37
+ globalThis[ASSET_GLOBAL_KEY] = normalizeAssetBaseUrl(baseUrl);
34
38
  }
35
39
  /** @public */
36
40
  export function getAssetBaseUrl() {
37
- return configuredAssetBaseUrl;
41
+ return globalThis[ASSET_GLOBAL_KEY] ?? '';
38
42
  }
39
43
  /**
40
44
  * 에셋 경로를 절대 URL 또는 베이스 URL 기준의 경로로 변환합니다.
@@ -10,8 +10,12 @@
10
10
  export async function copyText(text) {
11
11
  // 1순위: execCommand (동기 — 제스처 유지, iframe 대응)
12
12
  if (typeof document !== 'undefined') {
13
+ // 숨긴 textarea 에 포커스를 옮기므로, 끝나면 원래 자리로 돌려준다 — 안 돌리면 복사 단추를
14
+ // 누른 뒤 키보드 포커스가 body 로 떨어져 스크린리더·Tab 이동이 처음부터 다시 시작된다.
15
+ const 원래포커스 = document.activeElement;
16
+ let ta = null;
13
17
  try {
14
- const ta = document.createElement('textarea');
18
+ ta = document.createElement('textarea');
15
19
  ta.value = text;
16
20
  ta.setAttribute('readonly', '');
17
21
  ta.style.position = 'fixed';
@@ -24,13 +28,23 @@ export async function copyText(text) {
24
28
  ta.select();
25
29
  ta.setSelectionRange(0, text.length);
26
30
  const ok = document.execCommand('copy');
27
- document.body.removeChild(ta);
28
31
  if (ok)
29
32
  return true;
30
33
  }
31
34
  catch {
32
35
  /* 폴백으로 진행 */
33
36
  }
37
+ finally {
38
+ // 성공·실패·던짐 어느 길이든 textarea 를 거두고 포커스를 되돌린다
39
+ if (ta?.parentNode)
40
+ ta.parentNode.removeChild(ta);
41
+ if (원래포커스 && 원래포커스 !== document.activeElement && typeof 원래포커스.focus === 'function' && 원래포커스.isConnected) {
42
+ try {
43
+ 원래포커스.focus({ preventScroll: true });
44
+ }
45
+ catch { /* 무시 */ }
46
+ }
47
+ }
34
48
  }
35
49
  // 2순위: Async Clipboard API
36
50
  try {
@@ -11,7 +11,8 @@
11
11
  * 곳곳에 흩어져 있어 여기 한 벌로 모은다.
12
12
  *
13
13
  * 문자열도 그대로 쓴다 — `throw '문구'` 로 던지는 자리가 있다. 끝내 못 읽는
14
- * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다.
14
+ * 값(숫자·null·빈 객체·빈 문구)일 때만 fallback 을 쓴다. fallback 을 주지 않으면 지금
15
+ * 언어의 '알 수 없는 오류'(`common.unknownError`), 주면 그 문구 그대로.
15
16
  *
16
17
  * 회원 앱은 `src/libs/errorUtils.ts` 가 이것을 다시 내보낸다 — 한 벌만 둔다.
17
18
  * @public