@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.
- package/CHANGELOG.md +133 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +19 -3
- package/dist/packages/core/src/components/Alert.js +1 -1
- package/dist/packages/core/src/components/BarChart.js +3 -1
- package/dist/packages/core/src/components/Button.css +5 -0
- package/dist/packages/core/src/components/Button.js +3 -3
- package/dist/packages/core/src/components/Checkbox.js +10 -2
- package/dist/packages/core/src/components/CodeBox.js +2 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
- package/dist/packages/core/src/components/IconGridMenu.js +25 -22
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +10 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
- package/dist/packages/core/src/components/Popup/Popup.css +9 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
- package/dist/packages/core/src/components/Popup/Popup.js +2 -2
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
- package/dist/packages/core/src/components/ProgressBar.js +5 -2
- package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
- package/dist/packages/core/src/components/Rating.js +3 -1
- package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
- package/dist/packages/core/src/components/RingChart.js +14 -3
- package/dist/packages/core/src/components/SideNav.js +6 -3
- package/dist/packages/core/src/components/SplashLogo.js +1 -2
- package/dist/packages/core/src/components/StatChart.js +1 -1
- package/dist/packages/core/src/components/Table.js +15 -5
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +8 -4
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
- package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +7 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
- package/dist/packages/core/src/components/barChartData.d.ts +8 -0
- package/dist/packages/core/src/components/barChartData.js +21 -0
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- package/dist/packages/core/src/components/useBarChartControl.js +5 -3
- package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
- package/dist/packages/core/src/context/I18nProvider.js +12 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
- package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
- package/dist/packages/core/src/hooks/useEditableList.js +20 -1
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
- package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
- package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
- package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
- package/dist/packages/core/src/i18n/locales/en.json +3 -1
- package/dist/packages/core/src/i18n/locales/ko.json +3 -1
- package/dist/packages/core/src/utils/date.js +26 -5
- package/dist/packages/core/src/utils/getTrainingData.js +11 -4
- package/dist/packages/core/src/utils/imageConfig.js +6 -3
- package/dist/packages/core/src/utils/speak.js +23 -3
- package/dist/packages/core/src/utils/textSort.js +12 -1
- package/dist/packages/core/src/utils/ttsConfig.js +10 -8
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +11 -4
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- 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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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" }),
|
|
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
|
-
|
|
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:
|
|
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 ?
|
|
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
|
-
|
|
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:
|
|
117
|
-
icon:
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
81
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
60
|
+
if (typeof onDone === 'function')
|
|
61
|
+
onDone(payload);
|
|
44
62
|
}
|
|
45
63
|
catch { }
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
}
|