@ihabdevteam/core 0.128.0 → 0.131.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 (120) hide show
  1. package/CHANGELOG.md +100 -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-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 +282 -80
  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/BottomNav.css +15 -0
  15. package/dist/packages/core/src/components/BottomNav.d.ts +6 -0
  16. package/dist/packages/core/src/components/BottomNav.js +12 -1
  17. package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
  18. package/dist/packages/core/src/components/BulkBar.js +9 -2
  19. package/dist/packages/core/src/components/Calendar.js +1 -1
  20. package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
  21. package/dist/packages/core/src/components/ColorPicker.js +4 -2
  22. package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
  23. package/dist/packages/core/src/components/DatePicker.js +9 -4
  24. package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
  25. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
  26. package/dist/packages/core/src/components/EmptyState.js +3 -1
  27. package/dist/packages/core/src/components/ErrorState.js +3 -1
  28. package/dist/packages/core/src/components/FilterPanel.js +7 -0
  29. package/dist/packages/core/src/components/Header.d.ts +6 -0
  30. package/dist/packages/core/src/components/Header.js +27 -1
  31. package/dist/packages/core/src/components/Icon.css +22 -0
  32. package/dist/packages/core/src/components/Icon.d.ts +7 -3
  33. package/dist/packages/core/src/components/Icon.js +7 -1
  34. package/dist/packages/core/src/components/IconGridMenu.css +4 -2
  35. package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
  36. package/dist/packages/core/src/components/IconGridMenu.js +6 -3
  37. package/dist/packages/core/src/components/LandingHeader.js +6 -3
  38. package/dist/packages/core/src/components/Loader.d.ts +1 -1
  39. package/dist/packages/core/src/components/Loader.js +7 -2
  40. package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
  41. package/dist/packages/core/src/components/NoticeBox.js +3 -1
  42. package/dist/packages/core/src/components/Pagination.d.ts +2 -2
  43. package/dist/packages/core/src/components/Pagination.js +2 -2
  44. package/dist/packages/core/src/components/PanelRow.js +5 -1
  45. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
  46. package/dist/packages/core/src/components/PassCard/PassCardBg.css +33 -0
  47. package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
  48. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
  49. package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
  50. package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
  51. package/dist/packages/core/src/components/Popup/Popup.css +1 -1
  52. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  53. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
  54. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
  55. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
  56. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
  57. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
  58. package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
  59. package/dist/packages/core/src/components/PromoBanner.js +23 -6
  60. package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
  61. package/dist/packages/core/src/components/RadioGroup.js +2 -2
  62. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
  63. package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
  64. package/dist/packages/core/src/components/Section.d.ts +7 -1
  65. package/dist/packages/core/src/components/Section.js +6 -2
  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 +34 -6
  100. package/dist/packages/core/src/i18n/locales/ko.json +34 -6
  101. package/dist/packages/core/src/tokens.css +2 -1
  102. package/dist/packages/core/src/utils/CategoryData.d.ts +12 -40
  103. package/dist/packages/core/src/utils/CategoryData.js +15 -10
  104. package/dist/packages/core/src/utils/assets.js +7 -3
  105. package/dist/packages/core/src/utils/clipboard.js +16 -2
  106. package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
  107. package/dist/packages/core/src/utils/errorMessage.js +19 -2
  108. package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
  109. package/dist/packages/core/src/utils/getTrainingData.js +8 -1
  110. package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
  111. package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
  112. package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
  113. package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
  114. package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
  115. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
  116. package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
  117. package/dist/packages/core/src/version.d.ts +1 -1
  118. package/dist/packages/core/src/version.js +1 -1
  119. package/dist/utils.cjs.js +2 -2
  120. 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",
@@ -172,7 +173,8 @@
172
173
  "wordTable": "Word Table Creator",
173
174
  "company": "Company Info",
174
175
  "more": "More",
175
- "moreMenu": "More menu"
176
+ "moreMenu": "More menu",
177
+ "badgeLabel": "{{label}}, {{count}}"
176
178
  },
177
179
  "settings": {
178
180
  "language": {
@@ -281,7 +283,11 @@
281
283
  "security": {
282
284
  "title": "Security",
283
285
  "password": {
284
- "label": "Password"
286
+ "label": "Password",
287
+ "currentRequired": "Enter your current password.",
288
+ "newRequired": "Enter a new password.",
289
+ "mismatch": "The new passwords do not match.",
290
+ "changed": "Your password has been changed."
285
291
  },
286
292
  "twoFactor": {
287
293
  "label": "Two-Factor Authentication",
@@ -310,7 +316,6 @@
310
316
  "label": "Contrast",
311
317
  "type": "Contrast Type",
312
318
  "count": "Contrast Count",
313
- "diffPhonemeCount": "Total differing phonemes",
314
319
  "n": "{{n}}"
315
320
  },
316
321
  "filter": {
@@ -403,7 +408,8 @@
403
408
  "notJudged": "Not judged",
404
409
  "replay": "Replay",
405
410
  "done": "Done",
406
- "viewResult": "View Results"
411
+ "viewResult": "View Results",
412
+ "noContent": "(No content)"
407
413
  },
408
414
  "up": "Move up",
409
415
  "down": "Move down",
@@ -502,6 +508,11 @@
502
508
  "copy": "Copy Link",
503
509
  "inactive": "Inactive",
504
510
  "title": "Homework Progress"
511
+ },
512
+ "duration": {
513
+ "minSec": "{{m}}m {{s}}s",
514
+ "min": "{{m}}m",
515
+ "sec": "{{s}}s"
505
516
  }
506
517
  },
507
518
  "payment": {
@@ -672,7 +683,9 @@
672
683
  "tab": {
673
684
  "cards": "Word Table Cards",
674
685
  "tables": "Word Tables"
675
- }
686
+ },
687
+ "publishedBadge": "Published",
688
+ "sharedBadge": "Shared"
676
689
  },
677
690
  "keyWords": {
678
691
  "title": "Key Words",
@@ -1362,5 +1375,20 @@
1362
1375
  "velar": "Velar",
1363
1376
  "glottal": "Glottal"
1364
1377
  }
1378
+ },
1379
+ "bulkBar": {
1380
+ "clearSelection": "Clear selection"
1381
+ },
1382
+ "debug": {
1383
+ "showResult": "Show {{type}} result"
1384
+ },
1385
+ "badge": {
1386
+ "selected": "Selected",
1387
+ "remove": "Remove",
1388
+ "exclude": "Excluded",
1389
+ "nthSelected": "Selection {{n}}"
1390
+ },
1391
+ "loader": {
1392
+ "loading": "Loading"
1365
1393
  }
1366
1394
  }
@@ -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": "기간 선택",
@@ -170,7 +171,8 @@
170
171
  "wordTable": "단어표 만들기",
171
172
  "company": "업체정보",
172
173
  "more": "더보기",
173
- "moreMenu": "더보기 메뉴"
174
+ "moreMenu": "더보기 메뉴",
175
+ "badgeLabel": "{{label}}, {{count}}개"
174
176
  },
175
177
  "settings": {
176
178
  "language": {
@@ -279,7 +281,11 @@
279
281
  "security": {
280
282
  "title": "보안",
281
283
  "password": {
282
- "label": "비밀번호"
284
+ "label": "비밀번호",
285
+ "currentRequired": "현재 비밀번호를 입력하세요.",
286
+ "newRequired": "새 비밀번호를 입력하세요.",
287
+ "mismatch": "새 비밀번호가 일치하지 않습니다.",
288
+ "changed": "비밀번호가 변경되었습니다."
283
289
  },
284
290
  "twoFactor": {
285
291
  "label": "2단계 인증",
@@ -308,7 +314,6 @@
308
314
  "label": "대립",
309
315
  "type": "대립 종류",
310
316
  "count": "대립 갯수",
311
- "diffPhonemeCount": "다른 음소의 총 갯수",
312
317
  "n": "{{n}}개"
313
318
  },
314
319
  "filter": {
@@ -401,7 +406,8 @@
401
406
  "notJudged": "미판정",
402
407
  "replay": "다시하기",
403
408
  "done": "완료",
404
- "viewResult": "결과 확인"
409
+ "viewResult": "결과 확인",
410
+ "noContent": "(내용없음)"
405
411
  },
406
412
  "up": "위로",
407
413
  "down": "아래로",
@@ -500,6 +506,11 @@
500
506
  "completedFmt": "완료 {{done}}/{{total}} · {{duration}}"
501
507
  },
502
508
  "visitSuffix": "의 방문"
509
+ },
510
+ "duration": {
511
+ "minSec": "{{m}}분 {{s}}초",
512
+ "min": "{{m}}분",
513
+ "sec": "{{s}}초"
503
514
  }
504
515
  },
505
516
  "payment": {
@@ -670,7 +681,9 @@
670
681
  "publishStore": "말귀자료실에 올리기",
671
682
  "unpublishStore": "말귀자료실에서 내리기"
672
683
  },
673
- "newBadge": "새로 만든 표"
684
+ "newBadge": "새로 만든 표",
685
+ "publishedBadge": "발행됨",
686
+ "sharedBadge": "공유 중"
674
687
  },
675
688
  "keyWords": {
676
689
  "title": "주요 단어",
@@ -1360,5 +1373,20 @@
1360
1373
  "velar": "연구개음",
1361
1374
  "glottal": "성문음"
1362
1375
  }
1376
+ },
1377
+ "bulkBar": {
1378
+ "clearSelection": "선택 해제"
1379
+ },
1380
+ "debug": {
1381
+ "showResult": "{{type}} 결과보기"
1382
+ },
1383
+ "badge": {
1384
+ "selected": "선택됨",
1385
+ "remove": "제거",
1386
+ "exclude": "제외",
1387
+ "nthSelected": "{{n}}번째 선택"
1388
+ },
1389
+ "loader": {
1390
+ "loading": "불러오는 중…"
1363
1391
  }
1364
1392
  }
@@ -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)와 분리 */
@@ -6,28 +6,22 @@ export declare const TRAINING_TYPES: {
6
6
  icon: string;
7
7
  desc: string;
8
8
  }[];
9
- /** @public */
10
- export declare const TOPIC_TYPES: ({
11
- key: string;
12
- label: string;
13
- icon: string;
14
- color: string;
15
- subs: {
16
- key: string;
17
- label: string;
18
- }[];
19
- desc?: undefined;
20
- } | {
9
+ /**
10
+ * 주제·세부 주제 목록(열쇠·이름·아이콘·색). **설명(desc)은 두지 않는다** — 코어·앱 어디에서도 주제 설명을
11
+ * 그리지 않고, 한때 7개 주제에 붙어 있던 '소음 환경 속 문장 이해 훈련' 은 훈련 유형(소음 하 문장 듣기)의
12
+ * 설명을 옮겨 붙인 값이었다(2026-09-14 걷음). 화면에 보이는 설명은 훈련 유형(`TRAINING_TYPES`)의 것이다.
13
+ * @public
14
+ */
15
+ export declare const TOPIC_TYPES: {
21
16
  key: string;
22
17
  label: string;
23
18
  icon: string;
24
- desc: string;
25
19
  color: string;
26
20
  subs: {
27
21
  key: string;
28
22
  label: string;
29
23
  }[];
30
- })[];
24
+ }[];
31
25
  /** 훈련 유형 i18n 키 매핑 */
32
26
  export declare const TRAINING_TYPE_I18N_KEY: Record<string, string>;
33
27
  /** 번역된 훈련 유형 레이블/설명 반환 */
@@ -38,7 +32,7 @@ export declare function translateTrainingTypes(t: TFunction): {
38
32
  desc: string;
39
33
  }[];
40
34
  /** 번역된 토픽/서브토픽 레이블 반환 — 컴포넌트에서 useMemo와 함께 사용 */
41
- export declare function translateTopicTypes(t: TFunction): ({
35
+ export declare function translateTopicTypes(t: TFunction): {
42
36
  label: string;
43
37
  subs: {
44
38
  label: string;
@@ -47,18 +41,7 @@ export declare function translateTopicTypes(t: TFunction): ({
47
41
  key: string;
48
42
  icon: string;
49
43
  color: string;
50
- desc?: undefined;
51
- } | {
52
- label: string;
53
- subs: {
54
- label: string;
55
- key: string;
56
- }[];
57
- key: string;
58
- icon: string;
59
- desc: string;
60
- color: string;
61
- })[];
44
+ }[];
62
45
  declare const _default: {
63
46
  TRAINING_TYPES: {
64
47
  key: string;
@@ -66,7 +49,7 @@ declare const _default: {
66
49
  icon: string;
67
50
  desc: string;
68
51
  }[];
69
- TOPIC_TYPES: ({
52
+ TOPIC_TYPES: {
70
53
  key: string;
71
54
  label: string;
72
55
  icon: string;
@@ -75,17 +58,6 @@ declare const _default: {
75
58
  key: string;
76
59
  label: string;
77
60
  }[];
78
- desc?: undefined;
79
- } | {
80
- key: string;
81
- label: string;
82
- icon: string;
83
- desc: string;
84
- color: string;
85
- subs: {
86
- key: string;
87
- label: string;
88
- }[];
89
- })[];
61
+ }[];
90
62
  };
91
63
  export default _default;