@ihabdevteam/core 0.134.0 → 0.136.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 (82) hide show
  1. package/CHANGELOG.md +133 -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 +19 -3
  8. package/dist/packages/core/src/components/Alert.js +1 -1
  9. package/dist/packages/core/src/components/BarChart.js +3 -1
  10. package/dist/packages/core/src/components/Button.css +5 -0
  11. package/dist/packages/core/src/components/Button.js +3 -3
  12. package/dist/packages/core/src/components/Checkbox.js +10 -2
  13. package/dist/packages/core/src/components/CodeBox.js +2 -1
  14. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
  15. package/dist/packages/core/src/components/IconGridMenu.js +25 -22
  16. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  17. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  18. package/dist/packages/core/src/components/Popup/ListPopup.js +10 -3
  19. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
  20. package/dist/packages/core/src/components/Popup/Popup.css +9 -0
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
  22. package/dist/packages/core/src/components/Popup/Popup.js +2 -2
  23. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  24. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  25. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
  26. package/dist/packages/core/src/components/ProgressBar.js +5 -2
  27. package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
  28. package/dist/packages/core/src/components/Rating.js +3 -1
  29. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  30. package/dist/packages/core/src/components/RingChart.js +14 -3
  31. package/dist/packages/core/src/components/SideNav.js +6 -3
  32. package/dist/packages/core/src/components/SplashLogo.js +1 -2
  33. package/dist/packages/core/src/components/StatChart.js +1 -1
  34. package/dist/packages/core/src/components/Table.js +15 -5
  35. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
  36. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
  37. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +8 -4
  38. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  39. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
  40. package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
  41. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
  42. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  43. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
  44. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
  45. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  46. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
  47. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
  48. package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
  49. package/dist/packages/core/src/components/Word/WordTableBlock.js +7 -3
  50. package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
  51. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
  52. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
  53. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
  54. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  55. package/dist/packages/core/src/components/barChartData.js +21 -0
  56. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  57. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  58. package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
  59. package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
  60. package/dist/packages/core/src/context/I18nProvider.js +12 -3
  61. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  62. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  63. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  64. package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
  65. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  66. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  67. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  68. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  69. package/dist/packages/core/src/i18n/locales/en.json +3 -1
  70. package/dist/packages/core/src/i18n/locales/ko.json +3 -1
  71. package/dist/packages/core/src/utils/date.js +26 -5
  72. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  73. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  74. package/dist/packages/core/src/utils/speak.js +23 -3
  75. package/dist/packages/core/src/utils/textSort.js +12 -1
  76. package/dist/packages/core/src/utils/ttsConfig.js +10 -8
  77. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  78. package/dist/packages/core/src/utils/wordTrainingRuntime.js +11 -4
  79. package/dist/packages/core/src/version.d.ts +1 -1
  80. package/dist/packages/core/src/version.js +1 -1
  81. package/dist/utils.cjs.js +2 -2
  82. package/package.json +1 -1
@@ -44,7 +44,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
44
44
  const measureRef = useRef(null);
45
45
  const chromeRef = useRef(null);
46
46
  const rulerRef = useRef(null);
47
- const [pages, setPages] = useState(null);
47
+ /* 쪽 나눔은 **어느 블록 구성(shape)으로 잰 것인지**와 함께 둔다. 블록 수가 줄면 다시 재는 layout effect 가 렌더 뒤에야 돌아,
48
+ 그 사이 렌더가 옛 쪽의 번호로 새 blocks 를 읽다 `blocks[i]` 가 없어 **화면 전체가 깨졌다**(선택 섹션을 끄거나 자료를 다시
49
+ 받아 행이 줄 때). 지금 구성의 것이 아니면 없는 것으로 본다. */
50
+ const [measured, setMeasured] = useState(null);
48
51
  /* **넘칠 때만 탭 자리를 만든다.** A4 한 장은 794px 이라 좁은 자리에서는 가로로
49
52
  넘치는데, 이 상자 안에는 포커스 갈 곳이 하나도 없다 — 그러면 키보드만 쓰는
50
53
  사람은 오른쪽 절반을 **영영 못 본다**(WCAG 2.1.1). 실측: 겉 560px 에 안 794px.
@@ -64,7 +67,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
64
67
  const ro = new ResizeObserver(재자);
65
68
  ro.observe(el);
66
69
  return () => ro.disconnect();
67
- }, [pages]);
70
+ }, [measured]);
68
71
  // 블록 구성이 바뀌었는지를 값 하나로 본다. blocks 배열 자체는 렌더마다 새로
69
72
  // 만들어지는 일이 흔해 의존성에 그대로 넣으면 측정 → setPages → 렌더 → 측정
70
73
  // 이 끝나지 않는다.
@@ -73,13 +76,31 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
73
76
  통째로 건너뛴다 — 고친 사람의 변경을 볼 수도 찾을 수도 없다.
74
77
  돌리는 값은 예전과 한 바이트도 다르지 않다. */
75
78
  const shape = blocks.map((b) => `${b.id}\u0000${b.section ?? ''}\u0000${b.group ?? ''}`).join('\u0001');
79
+ const pages = measured && measured.shape === shape ? measured.pages : null;
80
+ /* 자(ruler)의 높이 — 0 에서 크기가 생기면(숨겨져 있다 보이게 됨) 다시 잰다 */
81
+ const [자크기, set자크기] = useState(0);
82
+ useEffect(() => {
83
+ const ruler = rulerRef.current;
84
+ if (!ruler || typeof ResizeObserver === 'undefined')
85
+ return;
86
+ const ro = new ResizeObserver(() => set자크기(ruler.offsetHeight));
87
+ ro.observe(ruler);
88
+ return () => ro.disconnect();
89
+ }, []);
76
90
  useLayoutEffect(() => {
77
91
  const el = measureRef.current;
78
92
  const ruler = rulerRef.current;
79
93
  if (!el || !ruler)
80
94
  return;
81
- const chromeH = chromeRef.current?.offsetHeight ?? 0;
82
- const budget = ruler.offsetHeight - chromeH;
95
+ /* display:none 안에서 처음 그리면 자·블록이 모두 0 이라 한 갈래가 한 장에 몰리는데, 보이게 된 뒤에도 구성이 같아 다시
96
+ 재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
97
+ /* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
98
+ 뒤 갈래의 장이 넘쳤다. */
99
+ const chromeOf = new Map();
100
+ chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
101
+ chromeOf.set(node.dataset.chromeSection ?? '', node.offsetHeight);
102
+ });
103
+ const budgetFor = (section) => ruler.offsetHeight - (chromeOf.get(section ?? '') ?? 0);
83
104
  const heights = Array.from(el.children).map((c) => c.offsetHeight);
84
105
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
85
106
  const groups = [];
@@ -103,6 +124,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
103
124
  };
104
125
  groups.forEach((grp) => {
105
126
  const groupH = grp.reduce((sum, i) => sum + heights[i], 0);
127
+ const budget = budgetFor(blocks[grp[0]].section);
106
128
  // 갈래가 바뀌면 새 장에서 시작한다.
107
129
  if (cur.length && blocks[cur[0]].section !== blocks[grp[0]].section)
108
130
  flush();
@@ -124,10 +146,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
124
146
  }
125
147
  });
126
148
  flush();
127
- setPages(packed);
149
+ setMeasured({ shape, pages: packed });
128
150
  // blocks 는 위 shape 로 대신 본다.
129
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [shape]);
152
+ }, [shape, 자크기]);
131
153
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
132
154
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
133
155
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -140,9 +162,9 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
140
162
  header: (_jsxs("div", { className: "report-doc__header", children: [_jsx("h2", { className: "report-doc__title h3b", children: (section != null && sectionTitles?.[section]) || null }), logo] })),
141
163
  footer: (_jsxs("div", { className: "report-doc__footer caption-1", children: [_jsx("span", { className: "report-doc__footer-left", children: footerLeft }), _jsx("span", { className: "report-doc__page-no", children: pageLabel(page, total) })] })),
142
164
  });
143
- // 머리·바닥이 쓰는 높이를 재기 위한 견본. 쪽 번호 자릿수만큼만 다르므로
144
- // 첫 장의 것으로 대신한다.
145
- const sampleChrome = chrome(1, 1, blocks[0]?.section);
165
+ // 머리·바닥이 쓰는 높이를 재기 위한 견본 — 갈래마다 하나(제목 유무로 머리 높이가 다르다). 쪽 번호 자릿수만큼만
166
+ // 다르므로 첫 장 번호로 잰다.
167
+ const chromeSections = Array.from(new Set(blocks.map((b) => b.section ?? '')));
146
168
  const vars = {
147
169
  ...(pageWidth ? { '--report-page-w': pageWidth } : null),
148
170
  ...(pageHeight ? { '--report-page-h': pageHeight } : null),
@@ -152,7 +174,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
152
174
  return (_jsxs("div", { ref: rootRef, className: cx('report-doc', className), style: vars,
153
175
  /* 굴릴 것이 있을 때만 포커스를 받는다. 이름 없이 포커스만 주면 낭독기가
154
176
  문서 전체를 읽어 버리므로 역할과 이름을 함께 준다. */
155
- tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsxs("div", { ref: chromeRef, children: [sampleChrome.header, sampleChrome.footer] }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
177
+ tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
178
+ const c = chrome(1, 1, sec || undefined);
179
+ return _jsxs("div", { "data-chrome-section": sec, children: [c.header, c.footer] }, sec);
180
+ }) }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
156
181
  const { header, footer: pageFooter } = chrome(p + 1, pages.length, blocks[idxs[0]].section);
157
182
  return (_jsxs("section", { className: "report-doc__page", children: [header, idxs.map((i) => (_jsx("div", { children: blocks[i].content }, blocks[i].id))), pageFooter] }, p));
158
183
  }) }))] }));
@@ -11,6 +11,15 @@ const RING_SIZE_PRESET = {
11
11
  large: { diameter: 160, strokeWidth: 22 },
12
12
  };
13
13
  /** 원시 링 렌더링 — 도넛 링 하나만 그린다(헤더/legend 없음) */
14
+ /** 비율 → 퍼센트 정수. **다 채우기 전에는 100 을 내지 않는다** — 반올림하면 199/200 이 '100%' 인데 링 끝은 비고 범례는
15
+ * '미완료 1건' 이었다(말귀 숙제 완료율). 같은 까닭으로 0 초과면 0 을 내지 않는다. */
16
+ function 퍼센트(ratio) {
17
+ if (!Number.isFinite(ratio) || ratio <= 0)
18
+ return 0;
19
+ if (ratio >= 1)
20
+ return 100;
21
+ return Math.min(99, Math.max(1, Math.round(ratio * 100)));
22
+ }
14
23
  function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', size = 'medium', strokeWidth, label = '%', hideCenter = false, hideLabel = false }) {
15
24
  const dim = typeof size === 'number' ? size : RING_SIZE_PRESET[size].diameter;
16
25
  const stroke = strokeWidth ?? (typeof size === 'number' ? 12 : RING_SIZE_PRESET[size].strokeWidth);
@@ -23,7 +32,9 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
23
32
  const acc = out.length > 0 ? out[out.length - 1].acc : 0;
24
33
  // 누적 비율이 1을 넘지 않도록 clamp — 호출자가 넘긴 ratio 합이 100%를 넘으면 이후
25
34
  // 구간이 원을 한 바퀴 넘어 겹쳐 그려지는 대신 조용히 잘리도록(오류 대신 방어적 저하)
26
- const ratio = Math.max(0, Math.min(s.ratio, 1 - acc));
35
+ // NaN(잘못된 날짜로 남은 날 수 등)은 0 으로 — 그대로 두면 누적값이 NaN 으로 번져 뒤 구간이 전부 사라졌다
36
+ const r = Number.isFinite(s.ratio) ? s.ratio : 0;
37
+ const ratio = Math.max(0, Math.min(r, 1 - acc));
27
38
  out.push({ color: s.color, rotate: -90 + acc * 360, dash: circumference * ratio, acc: acc + ratio });
28
39
  return out;
29
40
  }, []);
@@ -32,7 +43,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
32
43
  const pct = (total ?? 0) > 0 ? Math.min((done ?? 0) / total, 1) : 0;
33
44
  const filled = circumference * pct;
34
45
  const gap = circumference - filled;
35
- return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: Math.round(pct * 100) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
46
+ return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: 퍼센트(pct) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
36
47
  }
37
48
  /**
38
49
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -44,7 +55,7 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
44
55
  const { t } = useTranslation();
45
56
  if (!titleKey)
46
57
  return _jsx(RingChartCanvas, { ...ringProps });
47
- const pct = (ringProps.total ?? 0) > 0 ? Math.round(Math.min((ringProps.done ?? 0) / ringProps.total, 1) * 100) : 0;
58
+ const pct = (ringProps.total ?? 0) > 0 ? 퍼센트(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
48
59
  // size='small' — 대시보드 작은 그리드용 블럭. 컨트롤(headRight)만 생략하고 total/legend는 medium과 동일하게 유지
49
60
  const isSmall = ringProps.size === 'small';
50
61
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
@@ -57,7 +57,10 @@ function SideNavButton(props) {
57
57
  export default function SideNav(props) {
58
58
  const { t } = useTranslation();
59
59
  const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange } = props || {};
60
- const handleModeSwitch = useCallback(() => onAppModeChange?.(appMode === 'malgwi' ? 'manager' : 'malgwi'), [onAppModeChange, appMode]);
60
+ /* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
61
+ 데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
62
+ const isManager = appMode === 'manager';
63
+ const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
61
64
  return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
62
65
  .filter((it) => !it.section && (it.key || it.label))
63
66
  .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
@@ -113,7 +116,7 @@ export default function SideNav(props) {
113
116
  }
114
117
  } })] }, it.key ?? `side-nav__item-${idx}`))), onAppModeChange && _jsx("div", { className: "side-nav__separator side-nav__separator--short" }), onAppModeChange && (_jsx(SideNavButton, { item: {
115
118
  key: '__mode-switch__',
116
- label: appMode === 'malgwi' ? t('nav.goToManager', '말귀매니저로 이동') : t('nav.goToMalgwi', '말귀로 이동'),
117
- icon: appMode === 'malgwi' ? 'arrow-right' : 'arrow-left',
119
+ label: isManager ? t('nav.goToMalgwi', '말귀로 이동') : t('nav.goToManager', '말귀매니저로 이동'),
120
+ icon: isManager ? 'arrow-left' : 'arrow-right',
118
121
  }, onClick: handleModeSwitch }))] }), _jsx(SideNavFooter, { activeKey: activeKey, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp })] }));
119
122
  }
@@ -33,8 +33,7 @@ export default function SplashLogo({ logo, alt, onDone, durationMs = 3000, }) {
33
33
  // 담는다. 타이머는 한 번만 걸고, 울릴 때 **그때의 최신 콜백**을 부른다.
34
34
  const onDoneRef = useLatestRef(onDone);
35
35
  useEffect(() => {
36
- if (!onDoneRef.current)
37
- return;
36
+ // onDone 이 없어도 타이머는 건다 — 처음에 없다가 나중에 들어오면(인증 확인 뒤 콜백을 넘기는 등) 전에는 끝내 안 걸려 스플래시가 남았다
38
37
  const timer = setTimeout(() => onDoneRef.current?.(), durationMs);
39
38
  return () => clearTimeout(timer);
40
39
  }, [durationMs, onDoneRef]);
@@ -30,7 +30,7 @@ export default function StatChart({ label, value, change, changeType, icon, tool
30
30
  }, [onClick]);
31
31
  return (_jsxs("div", { ref: cardRef, className: `manager-stat-card${onClick ? ' manager-stat-card--clickable' : ''}`, onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, onPointerEnter: handleAnchorEnter, onPointerLeave: handleAnchorLeave,
32
32
  // hover 전용이면 키보드로는 툴팁 내용을 볼 수 없다(ManagerPage와 동일 처리)
33
- onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${label} ${value}` : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
33
+ onFocus: handleAnchorEnter, onBlur: handleAnchorLeave, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? [label, value, changeText].filter((v) => v !== undefined && v !== '').join(' ') : undefined, children: [tooltip && anchorRect && (_jsx(TooltipInfo, { anchorRect: {
34
34
  left: anchorRect.left,
35
35
  top: anchorRect.top + 21,
36
36
  right: anchorRect.right,
@@ -77,17 +77,27 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
77
77
  const defaultColsRef = useRef([]);
78
78
  // fit 모드에서는 이웃(next)에서 같은 만큼 덜어 오므로 그 컬럼의 시작 폭도 들고 있어야 한다
79
79
  const dragRef = useRef(null);
80
- const [colWidths, setColWidths] = useState(() => {
81
- if (!storeKey || typeof window === 'undefined')
80
+ /* 저장한 폭 읽기 — 숫자이기만 하면 받던 것을 **유한하고 최소 폭 이상인 수**만 받는다(0·음수·NaN 이 들어오면 격자 틀이
81
+ 무효가 됐다). */
82
+ const 저장폭읽기 = (key) => {
83
+ if (!key || typeof window === 'undefined')
82
84
  return null;
83
85
  try {
84
- const arr = JSON.parse(window.localStorage.getItem(storeKey) ?? 'null');
85
- return Array.isArray(arr) && arr.every((n) => typeof n === 'number') ? arr : null;
86
+ const arr = JSON.parse(window.localStorage.getItem(key) ?? 'null');
87
+ return Array.isArray(arr) && arr.length > 0 && arr.every((n) => typeof n === 'number' && Number.isFinite(n) && n > 0) ? arr : null;
86
88
  }
87
89
  catch {
88
90
  return null;
89
91
  }
90
- });
92
+ };
93
+ const [colWidths, setColWidths] = useState(() => 저장폭읽기(storeKey));
94
+ /* 마운트된 채 resizeStorageKey 가 바뀌면 **새 열쇠의 폭**을 읽는다 — 마운트 때 한 번만 읽어, 열 수가 같으면 앞 열쇠의 폭이
95
+ 다른 열에 그대로 씌워졌다. 렌더 중에 맞춘다(바뀐 그 렌더에서 옛 폭이 한 번이라도 그려지지 않게). */
96
+ const [읽은열쇠, set읽은열쇠] = useState(storeKey);
97
+ if (읽은열쇠 !== storeKey) {
98
+ set읽은열쇠(storeKey);
99
+ setColWidths(저장폭읽기(storeKey));
100
+ }
91
101
  // 최신 colWidths를 참조로 유지 (드래그 종료 시 저장용)
92
102
  const colWidthsRef = useRef(colWidths);
93
103
  useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Tooltip from './Tooltip';
6
6
  import './TrainingTooltip.css';
7
- import { TRAINING_TYPES } from '../../utils/CategoryData';
7
+ import { translateTrainingTypes } from '../../utils/CategoryData';
8
8
  import { getTrainingPools } from '../../utils/getTrainingData';
9
9
  import TrainingBlockButton from '../Training/TrainingBlockButton';
10
10
  /** 타입 칩 호버 → TrainingTooltip 표시까지의 지연(ms). 말귀자료실 카드와
@@ -20,10 +20,11 @@ function findTrainingData(sel) {
20
20
  /** @public */
21
21
  export default function TrainingTooltip({ plan, anchorRect, offset = 12, onClose }) {
22
22
  const { t } = useTranslation();
23
- const groups = useMemo(() => TRAINING_TYPES.map(t => ({
24
- type: t,
25
- items: (plan.items ?? []).filter(i => i.trainingType === t.key),
26
- })).filter(g => g.items.length > 0), [plan.items]);
23
+ // 머리글은 지금 언어로 — 원본 목록의 한국어 라벨을 써 영어 화면에도 '짧은 이야기 듣기' 가 떴다
24
+ const groups = useMemo(() => translateTrainingTypes(t).map(type => ({
25
+ type,
26
+ items: (plan.items ?? []).filter(i => i.trainingType === type.key),
27
+ })).filter(g => g.items.length > 0), [plan.items, t]);
27
28
  if (groups.length === 0)
28
29
  return null;
29
30
  const box = (_jsx("div", { className: "tooltip-training__box", children: _jsx("div", { className: "tooltip-training__groups", children: groups.flatMap(({ type, items }, idx) => [
@@ -278,6 +278,63 @@
278
278
  justify-content: center;
279
279
  }
280
280
 
281
+ /* ── row 변형 — 전체 너비 가로 행 ─────────────────────────────────────────────────
282
+ 정사각형 카드는 폭이 줄면 내용이 눌린다. 좁은 화면 목록에서 한 줄에 한 장씩
283
+ [아이콘][유형/제목][문항수] 를 모두 보이게 grid 로 다시 놓는다. 미리 듣기 단추는 그리지 않는다. */
284
+ .training-block-button--row {
285
+ width: 100%;
286
+ height: auto;
287
+ min-height: 68px;
288
+ display: flex;
289
+ align-items: center;
290
+ }
291
+ .training-block-button--row .training-block-button__left {
292
+ display: grid;
293
+ grid-template-columns: auto 1fr auto;
294
+ grid-template-areas:
295
+ "icon type count"
296
+ "icon title count";
297
+ align-items: center;
298
+ align-content: center;
299
+ height: auto;
300
+ column-gap: var(--spacing-s);
301
+ row-gap: 4px;
302
+ padding: 8px 0;
303
+ }
304
+ .training-block-button--row .category-icon__wrap {
305
+ grid-area: icon;
306
+ align-self: center;
307
+ }
308
+ .training-block-button--row .training-block-button__type-label {
309
+ grid-area: type;
310
+ align-self: end;
311
+ position: static;
312
+ inset: auto;
313
+ }
314
+ .training-block-button--row .training-block-button__title {
315
+ grid-area: title;
316
+ align-self: start;
317
+ position: static;
318
+ inset: auto;
319
+ white-space: nowrap;
320
+ overflow: hidden;
321
+ text-overflow: ellipsis;
322
+ }
323
+ .training-block-button--row .training-block-button__description {
324
+ grid-area: count;
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 4px;
328
+ position: static;
329
+ inset: auto;
330
+ height: auto;
331
+ }
332
+ /* 유형 줄이 없으면 제목이 두 줄 자리 가운데에 선다 */
333
+ .training-block-button--row:not(.training-block-button--withType) .training-block-button__title {
334
+ grid-row: 1 / span 2;
335
+ align-self: center;
336
+ }
337
+
281
338
  /* preview play button: small 카드 우측 상단 — 좌측 아이콘(top:12)과 수평 정렬 */
282
339
  .training-block-button--small .training-block-button__preview {
283
340
  position: absolute;
@@ -47,7 +47,9 @@ export default function TrainingBlockButton(props) {
47
47
  if (btnHover && !open && useDescriptionTooltip)
48
48
  setBtnAnchorRect(btnRef.current?.getBoundingClientRect() ?? null);
49
49
  }, [btnHover, open, useDescriptionTooltip]);
50
- const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' || e.key === ' ') {
50
+ /* 블록 자신에서 난 키만 — 안의 '더보기'·'미리 듣기' 단추에서 누른 Enter 를 가로채 훈련 팝업을 열었다(메뉴를 키보드로 못 엶) */
51
+ const handleKeyDown = useCallback((e) => { if (e.target !== e.currentTarget)
52
+ return; if (e.key === 'Enter' || e.key === ' ') {
51
53
  e.preventDefault();
52
54
  handleActivate();
53
55
  } }, [handleActivate]);
@@ -165,7 +167,9 @@ export default function TrainingBlockButton(props) {
165
167
  }
166
168
  }
167
169
  catch (_) { }
168
- const isList = size === 'list';
170
+ const isRow = size === 'row';
171
+ // row 는 list 의 바탕 위에 배치만 바꾼다
172
+ const isList = size === 'list' || isRow;
169
173
  const isCompact = size === 'compact';
170
174
  const isSmall = size === 'small';
171
175
  const trainingTypeName = isSmall && type?.key
@@ -174,11 +178,11 @@ export default function TrainingBlockButton(props) {
174
178
  // 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
175
179
  const typeLabelRendered = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ||
176
180
  (isSmall && !!trainingTypeName && trainingTypeName !== String(itemTitle));
177
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
181
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isRow ? ' training-block-button--row' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (typeof itemTitle === 'string' ? itemTitle : undefined), children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && btnHover && (_jsx(Badge, { type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(() => {
178
182
  const countText = countLabelOverride !== undefined ? countLabelOverride : countLabel;
179
183
  // 정해진 문항수가 없으면(빈 값) 카운트 요소 자체를 그리지 않는다.
180
184
  return countText ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: countText })) : null;
181
- })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), (isList || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
185
+ })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), ((isList && !isRow) || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
182
186
  trainingData: topic,
183
187
  typeKey: type?.key ?? '',
184
188
  onClose: handleClose,
@@ -203,6 +203,9 @@ function TrainingPagePopupInner(props) {
203
203
  setMainVolume,
204
204
  }), [noiseSettings, mergeAndSaveSettings, registerAnalyser, mainAnalyser, noiseAnalyser, playbackRate, setPlaybackRate, mainVolume, setMainVolume]);
205
205
  const panelRef = useRef(null);
206
+ /* '다시하기' 횟수 — 단계들의 key 에 넣어 **처음부터 다시 그린다.** 단계 컴포넌트는 내용(문장·보기)이 바뀔 때만 제 상태를
207
+ 비워, 같은 문항을 다시 풀면 옛 선택 강조·옛 배치(순서화는 정답 배치일 수도)가 그대로 남았다. */
208
+ const [replayCount, setReplayCount] = useState(0);
206
209
  const [mode, setMode] = useState('center');
207
210
  const [closing, setClosing] = useState(false);
208
211
  // 닫힘 애니메이션(PAGE_POPUP_CLOSE_MS) 동안 다른 닫기 경로(ESC/닫기 버튼/완료)가 또 실행되면
@@ -728,16 +731,38 @@ function TrainingPagePopupInner(props) {
728
731
  return null;
729
732
  }
730
733
  };
734
+ /* 다음 문제로 넘어가면 포커스를 새 문제의 첫 칸으로. '다음 문제' 는 누르는 즉시 비활성이 되어 포커스가 body 로 빠졌다 —
735
+ 키보드 사용자는 매 문제 판 처음부터 Tab 을 다시 밟았다. 포커스가 판 안 다른 곳에 있으면 건드리지 않는다. */
736
+ const 앞쪽Ref = useRef(undefined);
737
+ useEffect(() => {
738
+ const 앞 = 앞쪽Ref.current;
739
+ 앞쪽Ref.current = effectivePageIndex;
740
+ if (앞 === undefined || 앞 === effectivePageIndex)
741
+ return;
742
+ const panel = panelRef.current;
743
+ if (!panel)
744
+ return;
745
+ const active = document.activeElement;
746
+ const 잃음 = !active || active === document.body || !panel.contains(active) || active.disabled === true;
747
+ if (!잃음)
748
+ return;
749
+ const 첫칸 = panel.querySelector(`.training-steps ${FOCUSABLE_SELECTOR.split(', ').join(', .training-steps ')}`)
750
+ ?? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).find((el) => !el.closest('[aria-hidden="true"]'));
751
+ try {
752
+ 첫칸?.focus();
753
+ }
754
+ catch (e) { }
755
+ }, [effectivePageIndex]);
731
756
  const scenarioContent = hasScenario
732
757
  ? scenarioPages.length > 0
733
- ? _jsx("div", { className: "training-steps", children: currentScenarioSteps.map((step, index) => renderScenarioStep(step, index)) })
758
+ ? _jsx("div", { className: "training-steps", children: currentScenarioSteps.map((step, index) => renderScenarioStep(step, index)) }, `replay-${replayCount}`)
734
759
  : (_jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "pp-empty", children: scenario?.emptyMessage || t('training.noQuestions', '문제가 없습니다.') }) }))
735
760
  : null;
736
761
  const childrenContent = isChildrenMode
737
762
  ? (_jsxs(TrainingPagePopupContext.Provider, { value: childrenContextValue, children: [currentChildrenPage?.pageTop ? (_jsx("div", { className: "training-page-top", children: currentChildrenPage.pageTop })) : null, _jsx("div", { className: "training-steps", children: compoundChildrenSteps.map((child, index) => {
738
763
  const el = child;
739
764
  return cloneElement(el, { ...el.props, _stepIndex: index, _totalSteps: compoundChildrenSteps.length, _stepKey: `step-${index}`, _pageIndex: effectivePageIndex });
740
- }) }), currentChildrenPage?.pageBottom ? (_jsx("div", { className: "training-page-bottom", children: currentChildrenPage.pageBottom })) : null] }))
765
+ }) }, `replay-${replayCount}`), currentChildrenPage?.pageBottom ? (_jsx("div", { className: "training-page-bottom", children: currentChildrenPage.pageBottom })) : null] }))
741
766
  : null;
742
767
  const handleResultDone = useCallback(() => {
743
768
  // 완료 정본 기록 (문장/단어/오늘의 듣기 공통) — 결과를 닫기 전에 캡처
@@ -796,6 +821,7 @@ function TrainingPagePopupInner(props) {
796
821
  contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
797
822
  onReplay: () => {
798
823
  setResultOverlay(null);
824
+ setReplayCount((n) => n + 1);
799
825
  if (hasScenario) {
800
826
  setFlowState(createScenarioFlowState());
801
827
  if (typeof scenario?.onReplay === 'function') {
@@ -24,7 +24,8 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
24
24
  const subtitleText = typeof subtitle === 'string' ? subtitle.trim() : subtitle;
25
25
  const hasName = !!(titleText || subtitleText);
26
26
  const resolvedTitle = hasName ? (titleText || subtitleText) : t('common.emptyTrainingName', '훈련 이름이 비어 있습니다.');
27
- const rawDepth = depthText || subtitle;
27
+ // subtitle 을 이미 제목으로 썼으면(title 이 빔) depth 폴백에 다시 쓰지 않는다 — 같은 글이 두 줄로 찍혔다
28
+ const rawDepth = depthText || (titleText ? subtitle : undefined);
28
29
  // 모바일: " · " 또는 " / " 구분자가 있으면 마지막 세그먼트만 사용
29
30
  const resolvedDepth = (isMobile && typeof rawDepth === 'string' && (rawDepth.includes(' · ') || rawDepth.includes(' / ')))
30
31
  ? rawDepth.split(/\s[·/]\s/).pop()
@@ -41,7 +41,8 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
41
41
  /* eslint-disable react-hooks/set-state-in-effect */
42
42
  useEffect(() => {
43
43
  setSelectedIndex(null);
44
- cardRefs.current = [];
44
+ /* 카드 ref 는 비우지 않는다 — 마운트 때도 도는 이 effect 가 막 붙은 ref 를 지워, 따로 쓴 단계에서는 다시 그려질
45
+ 일이 없어 '모두 듣기' 가 소리 하나 없이 끝났다. 빠진 카드는 ref 콜백이 null 을 넣고, 재생은 보이는 칸까지만 돈다. */
45
46
  }, [itemsKey]);
46
47
  /* eslint-enable react-hooks/set-state-in-effect */
47
48
  const handlePlayDone = useCallback((item, i) => {
@@ -66,7 +67,7 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
66
67
  return;
67
68
  setPlayingAll(true);
68
69
  try {
69
- for (const cardRef of cardRefs.current) {
70
+ for (const cardRef of cardRefs.current.slice(0, shown.length)) {
70
71
  if (!cardRef)
71
72
  continue;
72
73
  await withTimeout(cardRef.play(), PLAY_ALL_CARD_TIMEOUT_MS);
@@ -75,7 +76,7 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, tit
75
76
  finally {
76
77
  setPlayingAll(false);
77
78
  }
78
- }, [playingAll, currentState]);
79
+ }, [playingAll, currentState, shown.length]);
79
80
  const handleSelect = useCallback((item, i) => {
80
81
  /* 끝낸 단계(done)에서는 고를 수 없다 — 단추는 `!== 'disabled'` 로 열려 있었는데
81
82
  기록은 `=== 'active'` 일 때만 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
@@ -1,12 +1,14 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useState, useEffect } from 'react';
4
+ import { useTranslation } from 'react-i18next';
4
5
  import TrainingStep from './TrainingStep';
5
6
  import TrainingWordCard from './TrainingWordCard';
6
7
  import './TrainingStep.css';
7
8
  import './TrainingStepCard.css';
8
9
  /** @public */
9
- export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title = '들은 순서대로 카드를 선택하세요.', choices = [], onDone, onChange, }) {
10
+ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false, title, choices = [], onDone, onChange, }) {
11
+ const { t } = useTranslation();
10
12
  const idx = typeof index === 'number' ? index : -1;
11
13
  const currentState = typeof stepIndex !== 'number'
12
14
  ? 'disabled'
@@ -17,42 +19,50 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
17
19
  : 'disabled';
18
20
  // selectedOrder[i] = 선택 순서 (1-based), null = 미선택
19
21
  const [selectedOrder, setSelectedOrder] = useState(() => choices.map(() => null));
20
- // choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지)
21
- const choicesKey = choices.join('|');
22
+ // choices 변경 시 초기화 (choicesKey로 내용까지 비교 — 개수만이 아닌 실제 단어 변경도 감지).
23
+ // 열쇠는 JSON 으로 — '|' 로 잇고 가르면 낱말에 '|' 가 든 경우 칸 수가 늘어 모두 골라도 끝나지 않았다.
24
+ const choicesKey = JSON.stringify(choices);
25
+ const choiceCount = choices.length;
22
26
  /* eslint-disable react-hooks/set-state-in-effect */
23
27
  useEffect(() => {
24
- setSelectedOrder(choicesKey ? choicesKey.split('|').map(() => null) : []);
28
+ setSelectedOrder(Array.from({ length: choiceCount }, () => null));
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
30
  }, [choicesKey]);
26
31
  /* eslint-enable react-hooks/set-state-in-effect */
27
32
  const nextRank = useMemo(() => selectedOrder.filter(v => v !== null).length + 1, [selectedOrder]);
33
+ /* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
34
+ 클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
28
35
  const handleSelect = useCallback((i) => {
29
36
  if (currentState !== 'active')
30
37
  return;
31
- setSelectedOrder(prev => {
32
- const arr = [...prev];
33
- if (arr[i] !== null) {
34
- // 이미 선택된 카드 → 해당 순서 이후 선택 모두 해제
35
- const rank = arr[i];
36
- return arr.map(v => (v !== null && v >= rank ? null : v));
37
- }
38
+ const prev = selectedOrder;
39
+ let arr;
40
+ if (prev[i] != null) {
41
+ // 이미 선택된 카드 → 해당 순서 이후 선택 모두 해제
42
+ const rank = prev[i];
43
+ arr = prev.map(v => (v !== null && v >= rank ? null : v));
44
+ }
45
+ else {
38
46
  // 미선택 카드 → 다음 순서 부여
47
+ arr = [...prev];
39
48
  arr[i] = nextRank;
40
- const payload = { selectedOrder: arr, words: choices, __ts: Date.now() };
49
+ }
50
+ setSelectedOrder(arr);
51
+ const payload = { selectedOrder: arr, words: choices, __ts: Date.now() };
52
+ try {
53
+ if (typeof onChange === 'function')
54
+ onChange(payload);
55
+ }
56
+ catch { }
57
+ // 새로 골라 모두 선택됐으면 완료
58
+ if (prev[i] == null && arr.length > 0 && arr.every(v => v !== null)) {
41
59
  try {
42
- if (typeof onChange === 'function')
43
- onChange(payload);
60
+ if (typeof onDone === 'function')
61
+ onDone(payload);
44
62
  }
45
63
  catch { }
46
- // 모두 선택됐으면 완료
47
- if (arr.every(v => v !== null)) {
48
- try {
49
- if (typeof onDone === 'function')
50
- onDone(payload);
51
- }
52
- catch { }
53
- }
54
- return arr;
55
- });
56
- }, [currentState, nextRank, onChange, onDone, choices]);
57
- return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: title, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
64
+ }
65
+ }, [currentState, selectedOrder, nextRank, onChange, onDone, choices]);
66
+ const resolvedTitle = title ?? t('training.selectInOrder', '들은 순서대로 카드를 선택하세요.');
67
+ return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, children: _jsx("div", { className: "training-step__cards", children: choices.map((word, i) => (_jsx(TrainingWordCard, { item: { word }, disabled: currentState === 'disabled', selectable: true, selected: selectedOrder[i] !== null, orderRank: selectedOrder[i], onSelect: () => handleSelect(i), flipDelay: i * 120, hideLabel: false, hideAudio: true }, `${word}-${i}`))) }) }));
58
68
  }