@ihabdevteam/core 0.133.0 → 0.135.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 +122 -0
- package/dist/components.cjs.js +176 -176
- package/dist/components.esm.css +1 -1
- 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 +8 -2
- package/dist/packages/core/src/components/BarChart.js +3 -1
- package/dist/packages/core/src/components/Breadcrumb.js +5 -1
- package/dist/packages/core/src/components/Dialog.js +32 -9
- package/dist/packages/core/src/components/Header.css +2 -0
- package/dist/packages/core/src/components/Header.js +4 -1
- package/dist/packages/core/src/components/LandingHeader.js +18 -1
- package/dist/packages/core/src/components/Pagination.js +5 -2
- package/dist/packages/core/src/components/Panel.d.ts +1 -1
- package/dist/packages/core/src/components/Panel.js +4 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
- package/dist/packages/core/src/components/PaymentPlan.js +5 -3
- 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 +7 -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/PromoBanner.js +1 -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 +1 -1
- package/dist/packages/core/src/components/SoundCompass.js +10 -1
- package/dist/packages/core/src/components/Table.js +15 -5
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
- 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/useBarChartControl.js +5 -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/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/hooks/useWordTrainingFilter.js +9 -4
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
- package/dist/packages/core/src/i18n/locales/en.json +13 -1
- package/dist/packages/core/src/i18n/locales/ko.json +13 -1
- package/dist/packages/core/src/utils/format.js +17 -1
- 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/koreanPronunciation.js +7 -0
- package/dist/packages/core/src/utils/pressable.js +4 -0
- package/dist/packages/core/src/utils/savedWordTables.js +2 -1
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +38 -17
- 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
|
@@ -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열)
|
|
@@ -51,7 +51,7 @@ function SideNavButton(props) {
|
|
|
51
51
|
}
|
|
52
52
|
onClick?.(key, e.currentTarget, fromPointer);
|
|
53
53
|
}, [isDisabled, key, onClick]);
|
|
54
|
-
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label":
|
|
54
|
+
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
55
55
|
}
|
|
56
56
|
/** @public */
|
|
57
57
|
export default function SideNav(props) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
'use client';
|
|
7
7
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
8
8
|
import { useMemo } from 'react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
9
10
|
import Check from 'lucide-react/dist/esm/icons/check.mjs';
|
|
10
11
|
import Volume2 from 'lucide-react/dist/esm/icons/volume-2.mjs';
|
|
11
12
|
import User from 'lucide-react/dist/esm/icons/user.mjs';
|
|
@@ -15,6 +16,7 @@ import './SoundCompass.css';
|
|
|
15
16
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
16
17
|
/** @public */
|
|
17
18
|
export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
|
|
19
|
+
const { t: 번역 } = useTranslation();
|
|
18
20
|
const t = Math.max(0, Math.min(1, viewT));
|
|
19
21
|
const CENTER = size / 2;
|
|
20
22
|
// ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
|
|
@@ -119,6 +121,13 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
119
121
|
}
|
|
120
122
|
// ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
|
|
121
123
|
const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
|
|
124
|
+
const stateLabel = (state) => ({
|
|
125
|
+
idle: '',
|
|
126
|
+
active: 번역('soundCompass.state.active', '소리 나는 중'),
|
|
127
|
+
played: 번역('soundCompass.state.played', '들음'),
|
|
128
|
+
correct: 번역('soundCompass.state.correct', '정답'),
|
|
129
|
+
wrong: 번역('soundCompass.state.wrong', '오답'),
|
|
130
|
+
})[state];
|
|
122
131
|
const getSpeakerState = (sp) => {
|
|
123
132
|
if (activeSpeakerId === sp.id)
|
|
124
133
|
return 'active';
|
|
@@ -209,6 +218,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
209
218
|
...(pulseScale !== undefined
|
|
210
219
|
? { ['--sc-pulse-scale']: String(pulseScale) }
|
|
211
220
|
: null),
|
|
212
|
-
} })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
|
|
221
|
+
} })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": stateLabel(state) ? `${sp.label}, ${stateLabel(state)}` : sp.label, "aria-pressed": selectedId != null ? sp.id === selectedId : undefined, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
|
|
213
222
|
})] }) }));
|
|
214
223
|
}
|
|
@@ -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]);
|
|
@@ -87,6 +87,11 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
87
87
|
return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
|
|
88
88
|
? 'chevron-right'
|
|
89
89
|
: item.badgeCount
|
|
90
|
-
? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-
|
|
91
|
-
: undefined, label: item.label,
|
|
90
|
+
? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-hidden": true })
|
|
91
|
+
: undefined, label: item.label,
|
|
92
|
+
/* 개수는 이름에 붙인다 — ariaLabel 이 글자뿐이라 배지 수가 이름에서 빠져 "알림" 으로만 읽혔다
|
|
93
|
+
(SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
|
|
94
|
+
ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
|
|
95
|
+
? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
|
|
96
|
+
: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
|
|
92
97
|
}
|
|
@@ -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]);
|
|
@@ -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') {
|
|
@@ -28,6 +28,8 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
28
28
|
return "active";
|
|
29
29
|
return "disabled";
|
|
30
30
|
}, [stepIndex, index]);
|
|
31
|
+
/** 옮기기 단추·끌어놓기를 여는가 — 진행 중이거나 끝낸 단계(아래 move 머리말) */
|
|
32
|
+
const 조작가능 = currentState !== 'disabled';
|
|
31
33
|
// context 변경 시 items/shuffled를 다시 초기화하는 의도된 동기화다
|
|
32
34
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
33
35
|
useEffect(() => {
|
|
@@ -75,11 +77,13 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
75
77
|
/**
|
|
76
78
|
* 카드 한 칸 옮기기.
|
|
77
79
|
*
|
|
78
|
-
* **조작은 진행 중(active)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* **조작은 진행 중(active)과 끝낸(done) 단계에서 열려 있다.** onDone 은 진행 중일 때만 부른다(단계를 넘기는 신호).
|
|
81
|
+
* 옮길 때마다 flipSetShuffled 가 onChange 로 새 배치·점수를 내보내고, OrderListStep 은 그것을 **이 단계 번호로**
|
|
82
|
+
* 기록하므로 끝낸 단계에서 옮겨도 점수가 따라간다.
|
|
83
|
+
*
|
|
84
|
+
* 한때(92b71ac) done 에서 조작을 닫았다 — "끝낸 단계에서 옮기면 점수가 안 나간다" 고 봤는데 위 onChange 기록을
|
|
85
|
+
* 놓친 판단이었고, 그 결과 **카드를 한 번 옮기는 순간**(첫 이동의 onDone 이 단계를 넘긴다) 단추·끌어놓기가 모두
|
|
86
|
+
* 사라져 문장 순서화를 끝까지 맞출 수 없었다(2026-09-14 훈련 검수 — 말귀 OrderTrainingPagePopup).
|
|
83
87
|
*/
|
|
84
88
|
const move = (idx, dir) => {
|
|
85
89
|
const arr = [...shuffled];
|
|
@@ -423,7 +427,7 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
423
427
|
map.set(it.id, el);
|
|
424
428
|
else
|
|
425
429
|
map.delete(it.id);
|
|
426
|
-
}, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable:
|
|
430
|
+
}, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable: 조작가능, onDragStart: 조작가능 ? (e) => handleDragStart(idx, e) : undefined, onDragEnter: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragOver: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragLeave: 조작가능 ? () => {
|
|
427
431
|
try {
|
|
428
432
|
if (dragLeaveTimeoutRef.current !== null)
|
|
429
433
|
clearTimeout(dragLeaveTimeoutRef.current);
|
|
@@ -440,6 +444,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
|
|
|
440
444
|
}, 80);
|
|
441
445
|
}
|
|
442
446
|
catch (err) { }
|
|
443
|
-
} : undefined, onDrop:
|
|
447
|
+
} : undefined, onDrop: 조작가능 ? (e) => handleDrop(idx, e) : undefined, onDragEnd: 조작가능 ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), 조작가능 ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
|
|
444
448
|
}) }) }));
|
|
445
449
|
}
|
|
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
70
70
|
fontSize: `${fontSize}px`,
|
|
71
71
|
lineHeight: `${lineHeight}px`,
|
|
72
72
|
}), [fontSize, lineHeight]);
|
|
73
|
-
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
73
|
+
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
74
74
|
}
|
|
@@ -223,6 +223,10 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
223
223
|
// 읽히는데 정작 고를 수가 없어, 훈련 문항을 키보드만으로 풀 수 없었다.
|
|
224
224
|
tabIndex: selectable && !disabled ? 0 : undefined, "aria-disabled": selectable && disabled ? true : undefined, onKeyDown: selectable && !disabled
|
|
225
225
|
? (e) => {
|
|
226
|
+
/* 카드 자신에서 난 키만 — 안의 '소리 듣기' 에서 누른 Enter 가 올라오면 전에는 소리 대신 **그 카드를 답으로
|
|
227
|
+
골라** 되돌릴 수 없었다. */
|
|
228
|
+
if (e.target !== e.currentTarget)
|
|
229
|
+
return;
|
|
226
230
|
// Space 는 기본 동작이 스크롤이라 막고, 둘 다 클릭과 같은 선택으로 잇는다.
|
|
227
231
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
228
232
|
e.preventDefault();
|
|
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
24
24
|
// 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
|
|
25
25
|
const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
|
|
26
26
|
const noiseTypeLoadedRef = useRef(null);
|
|
27
|
+
/* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
|
|
28
|
+
전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
|
|
29
|
+
fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
|
|
30
|
+
const pendingNoiseUrlRef = useRef(null);
|
|
27
31
|
// volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
|
|
28
32
|
const noiseSettingsRef = useRef(null);
|
|
29
33
|
const noiseFadeRafRef = useRef(null);
|
|
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
43
47
|
}, []);
|
|
44
48
|
const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
|
|
45
49
|
const noiseEl = noiseAudioRef.current;
|
|
46
|
-
if (!noiseEl
|
|
50
|
+
if (!noiseEl)
|
|
51
|
+
return;
|
|
52
|
+
const pending = pendingNoiseUrlRef.current;
|
|
53
|
+
if (pending) {
|
|
54
|
+
pendingNoiseUrlRef.current = null;
|
|
55
|
+
try {
|
|
56
|
+
noiseEl.pause();
|
|
57
|
+
}
|
|
58
|
+
catch (e) { }
|
|
59
|
+
noiseEl.src = pending;
|
|
60
|
+
noiseEl.load();
|
|
61
|
+
}
|
|
62
|
+
if (!noiseEl.src)
|
|
47
63
|
return;
|
|
48
64
|
noiseFadeTokenRef.current += 1;
|
|
49
65
|
cancelNoiseFade();
|
|
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
235
251
|
if (url) {
|
|
236
252
|
noiseTypeLoadedRef.current = ns.type;
|
|
237
253
|
setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
|
|
254
|
+
pendingNoiseUrlRef.current = url;
|
|
238
255
|
fadeOutNoise(150, () => {
|
|
256
|
+
// 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
|
|
257
|
+
if (pendingNoiseUrlRef.current !== url)
|
|
258
|
+
return;
|
|
259
|
+
pendingNoiseUrlRef.current = null;
|
|
239
260
|
noiseEl.src = url;
|
|
240
261
|
noiseEl.load();
|
|
241
262
|
const mainAudio = audioRef.current;
|
|
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
13
13
|
const ttsStartRef = useRef(null);
|
|
14
14
|
const ttsEstimatedDurationRef = useRef(0);
|
|
15
15
|
const ttsRafRef = useRef(null);
|
|
16
|
+
/* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
|
|
17
|
+
end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
|
|
18
|
+
언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
|
|
19
|
+
const ttsTokenRef = useRef(0);
|
|
16
20
|
useEffect(() => {
|
|
17
21
|
// cleanup TTS resources on unmount
|
|
18
22
|
return () => {
|
|
23
|
+
ttsTokenRef.current++;
|
|
19
24
|
try {
|
|
20
25
|
if (ttsRafRef.current) {
|
|
21
26
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
54
59
|
const synth = window.speechSynthesis;
|
|
60
|
+
const myToken = ++ttsTokenRef.current;
|
|
55
61
|
try {
|
|
56
62
|
try {
|
|
57
63
|
synth.cancel();
|
|
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
99
105
|
catch (e) { }
|
|
100
106
|
};
|
|
101
107
|
utt.onend = () => {
|
|
108
|
+
if (myToken !== ttsTokenRef.current)
|
|
109
|
+
return;
|
|
102
110
|
try {
|
|
103
111
|
if (ttsRafRef.current) {
|
|
104
112
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
149
157
|
}
|
|
150
158
|
}, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
|
|
151
159
|
const stopTTS = useCallback(() => {
|
|
160
|
+
ttsTokenRef.current++;
|
|
152
161
|
try {
|
|
153
162
|
if (window.speechSynthesis) {
|
|
154
163
|
try {
|
|
@@ -105,9 +105,10 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
105
105
|
}
|
|
106
106
|
return [...byLabel.entries()].map(([label, its]) => ({ label, items: its }));
|
|
107
107
|
}, [groups, orderedGroupItems, pageClamped, pageSize]);
|
|
108
|
-
// 정렬/페이지크기 변경·결과 개수
|
|
108
|
+
// 정렬/페이지크기 변경·결과 개수 변동·**검색어 변경** 시 1페이지로. 검색어를 안 보면 결과 수가 같은 다른 검색에서
|
|
109
|
+
// 보던 쪽 번호(3쪽)가 남아 새 결과의 3쪽이 떴다.
|
|
109
110
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
110
|
-
useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount]);
|
|
111
|
+
useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount, searchQuery]);
|
|
111
112
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
112
113
|
// 결과가 있으면(flat/그룹 모두) 툴바(정렬·페이지크기·페이지네이션) 노출.
|
|
113
114
|
// (그룹 뷰도 flat과 동일하게 페이지네이션 — 카드 4 결정 1안. 그룹은 페이지 단위로 재그룹핑되어 표시.)
|
|
@@ -233,7 +233,8 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
233
233
|
const saveAsTrimmed = saveAsName.trim();
|
|
234
234
|
const saveAsCollides = saveAsTrimmed !== '' && namesLower.includes(saveAsTrimmed.toLowerCase());
|
|
235
235
|
const openSaveAsPopup = () => {
|
|
236
|
-
|
|
236
|
+
// 복사본 이름도 사전을 탄다 — 박혀 있어 영어 화면에 'My table (복사)' 가 채워졌다
|
|
237
|
+
setSaveAsName(trimmedName ? t('wordTable.save.copyName', { name: trimmedName, defaultValue: '{{name}} (복사)' }) : '');
|
|
237
238
|
setShowSaveAsPopup(true);
|
|
238
239
|
};
|
|
239
240
|
const handleSaveAsConfirm = () => {
|
|
@@ -53,4 +53,12 @@ export declare const MONTH_OPTIONS: {
|
|
|
53
53
|
year: number;
|
|
54
54
|
month: number;
|
|
55
55
|
}[];
|
|
56
|
+
/** 지금 시각 기준 월 옵션 — 모듈 상수(MONTH_OPTIONS)는 불러온 순간의 달에 굳어, 탭을 열어 둔 채 달이 넘어가면 이번 달이
|
|
57
|
+
* 옵션에서 빠졌다. 컨트롤 훅은 자료가 바뀔 때마다 이것으로 다시 만든다. */
|
|
58
|
+
export declare function monthOptionsNow(): {
|
|
59
|
+
value: string;
|
|
60
|
+
label: string;
|
|
61
|
+
year: number;
|
|
62
|
+
month: number;
|
|
63
|
+
}[];
|
|
56
64
|
export declare const KO_ORD: string[];
|
|
@@ -48,6 +48,24 @@ export function buildMonthChartData(points) {
|
|
|
48
48
|
byMonth.set(key, []);
|
|
49
49
|
byMonth.get(key).push(p);
|
|
50
50
|
}
|
|
51
|
+
/* **앞 달 칸도 만든다.** 첫 월요일 이전 날(9/1~9/6)은 앞 달의 마지막 주에 속하는데, 앞 달에 자료가 없으면 그 달 칸이
|
|
52
|
+
안 생겨 그 날들이 **어느 막대에도 안 들어갔다**(신규 회원·한 달 쉰 회원의 달 초 훈련이 사라짐). 오늘이 든 주도 앞 달
|
|
53
|
+
마지막 주면 주 보기 기본 선택이 미래 주로 갔다. 그 날에 자료가 있거나 오늘이 그 날이면 앞 달을 빈 칸으로라도 둔다. */
|
|
54
|
+
for (const [key, pts] of [...byMonth.entries()]) {
|
|
55
|
+
const m = key.match(/^(\d+)년 (\d+)월$/);
|
|
56
|
+
if (!m)
|
|
57
|
+
continue;
|
|
58
|
+
const year = Number(m[1]);
|
|
59
|
+
const month0 = Number(m[2]) - 1;
|
|
60
|
+
const 첫월 = firstMondayOfMonth(year, month0);
|
|
61
|
+
const 오늘이앞 = key === curKey && now.getDate() < 첫월;
|
|
62
|
+
if (!오늘이앞 && !pts.some(p => p.date.getDate() < 첫월))
|
|
63
|
+
continue;
|
|
64
|
+
const prev = new Date(year, month0, 0);
|
|
65
|
+
const prevKey = 달키(prev.getFullYear(), prev.getMonth());
|
|
66
|
+
if (!byMonth.has(prevKey))
|
|
67
|
+
byMonth.set(prevKey, []);
|
|
68
|
+
}
|
|
51
69
|
const 칸들 = new Map();
|
|
52
70
|
for (const [key, pts] of byMonth) {
|
|
53
71
|
const m = key.match(/^(\d+)년 (\d+)월$/);
|
|
@@ -138,5 +156,8 @@ function genMonthOptions() {
|
|
|
138
156
|
return opts.reverse();
|
|
139
157
|
}
|
|
140
158
|
export const MONTH_OPTIONS = genMonthOptions();
|
|
159
|
+
/** 지금 시각 기준 월 옵션 — 모듈 상수(MONTH_OPTIONS)는 불러온 순간의 달에 굳어, 탭을 열어 둔 채 달이 넘어가면 이번 달이
|
|
160
|
+
* 옵션에서 빠졌다. 컨트롤 훅은 자료가 바뀔 때마다 이것으로 다시 만든다. */
|
|
161
|
+
export function monthOptionsNow() { return genMonthOptions(); }
|
|
141
162
|
// 한글 서수(주차) — '첫째주', '둘째주' …
|
|
142
163
|
export const KO_ORD = ['첫째', '둘째', '셋째', '넷째', '다섯째', '여섯째', '일곱째'];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { firstMondayOfMonth, todayWeekValue,
|
|
4
|
+
import { firstMondayOfMonth, todayWeekValue, monthOptionsNow, KO_ORD } from './barChartData';
|
|
5
5
|
/**
|
|
6
6
|
* BarChart 기간 상태/옵션/네비게이션을 캡슐화한 훅.
|
|
7
7
|
* 여러 BarChart가 하나의 컨트롤을 공유하려면 이 훅을 상위에서 호출해 control을 내려준다.
|
|
@@ -10,7 +10,8 @@ import { firstMondayOfMonth, todayWeekValue, MONTH_OPTIONS, KO_ORD } from './bar
|
|
|
10
10
|
export function useBarChartControl(data) {
|
|
11
11
|
const { t, i18n } = useTranslation();
|
|
12
12
|
const [period, setPeriod] = useState('week');
|
|
13
|
-
|
|
13
|
+
// 달 옵션·오늘은 자료가 바뀔 때 다시 센다 — 모듈 로드 때 한 번 세면 자정을 넘긴 탭에서 이번 달·오늘 주가 옛날에 굳었다
|
|
14
|
+
const DATA_MONTH_OPTIONS = useMemo(() => monthOptionsNow().filter(o => o.value in data), [data]);
|
|
14
15
|
const WEEK_OPTIONS = useMemo(() => {
|
|
15
16
|
const opts = [];
|
|
16
17
|
for (const o of DATA_MONTH_OPTIONS) {
|
|
@@ -34,7 +35,8 @@ export function useBarChartControl(data) {
|
|
|
34
35
|
})), [t, DATA_MONTH_OPTIONS]);
|
|
35
36
|
const weekOptions = useMemo(() => WEEK_OPTIONS.map(o => ({ value: o.value, label: weekOptionLabel(o) })), [WEEK_OPTIONS, weekOptionLabel]);
|
|
36
37
|
// 진입 시 기본 선택을 오늘 기준으로 — 오늘이 속한 월/주(데이터에 있으면), 없으면 최신
|
|
37
|
-
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
const today = useMemo(() => todayWeekValue(), [data]);
|
|
38
40
|
const defaultMonth = useMemo(() => DATA_MONTH_OPTIONS.find(o => o.value === today.monthKey)?.value ?? DATA_MONTH_OPTIONS[0]?.value ?? '', [DATA_MONTH_OPTIONS, today]);
|
|
39
41
|
const defaultWeek = useMemo(() => WEEK_OPTIONS.find(o => o.value === today.value)?.value ?? WEEK_OPTIONS[0]?.value ?? '', [WEEK_OPTIONS, today]);
|
|
40
42
|
const [monthRange, setMonthRange] = useState('');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { ensureTrainingDataLoaded } from '../utils/getTrainingData';
|
|
4
|
+
import { getTodayKst } from '../utils/date';
|
|
4
5
|
import { hasItems, resolveSelection, } from '../utils/dailySelectionUtils';
|
|
5
6
|
// ─── 훅 ──────────────────────────────────────────────────────────────────────
|
|
6
7
|
/**
|
|
@@ -29,6 +30,19 @@ export function useDailyTrainingSelection(userId) {
|
|
|
29
30
|
// 가를 수가 없어** 설명 없는 빈 화면을 그리게 된다(실제로 그런 화면이 있다).
|
|
30
31
|
// loaded 의 뜻을 바꾸면 지금 쓰는 곳이 깨지므로, 가를 수 있는 값을 따로 낸다.
|
|
31
32
|
const [hasData, setHasData] = useState(false);
|
|
33
|
+
/* 오늘(KST) — 자정을 넘기면 바뀌어 아래 선택을 **새 날짜 시드로** 다시 계산하게 한다. 전에는 userId 만 봐서 화면을 열어 둔 채
|
|
34
|
+
자정을 넘기면 어제 시드의 세트가 '오늘' 로 보이는데 완료 기록에는 새 날짜가 찍혔다. */
|
|
35
|
+
const [오늘, set오늘] = useState(() => getTodayKst());
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const 다음자정까지 = () => {
|
|
38
|
+
const 지금 = Date.now();
|
|
39
|
+
const kst = new Date(지금 + 9 * 3600_000);
|
|
40
|
+
const 자정 = Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate() + 1) - 9 * 3600_000;
|
|
41
|
+
return Math.max(1000, 자정 - 지금 + 1000);
|
|
42
|
+
};
|
|
43
|
+
let t = setTimeout(function 넘김() { set오늘(getTodayKst()); t = setTimeout(넘김, 다음자정까지()); }, 다음자정까지());
|
|
44
|
+
return () => clearTimeout(t);
|
|
45
|
+
}, []);
|
|
32
46
|
useEffect(() => {
|
|
33
47
|
let cancelled = false;
|
|
34
48
|
let timer;
|
|
@@ -90,7 +104,9 @@ export function useDailyTrainingSelection(userId) {
|
|
|
90
104
|
tryFill();
|
|
91
105
|
timer = setInterval(tryFill, 250);
|
|
92
106
|
return () => { cancelled = true; stop(); };
|
|
93
|
-
|
|
107
|
+
// 오늘 은 날짜가 바뀌었다는 신호 — resolveSelection 이 안에서 오늘 날짜를 읽는다
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [userId, 오늘]);
|
|
94
110
|
const getTrainingPerType = useCallback((typeKey) => {
|
|
95
111
|
const src = showYesterday ? yesterdayRef.current : todayRef.current;
|
|
96
112
|
const fallback = showYesterday ? todayRef.current : yesterdayRef.current;
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @public
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* 같은 항목인가 — 같은 참조이거나, **둘 다 비지 않은 같은 id 를 가지면** 같다(빈 id 는 새로 만든 로컬 항목이라 견주지 않는다).
|
|
8
|
+
* 서버에서 목록을 다시 받으면 항목이 모두 새 객체가 되는데, 고른 항목·지울 항목을 참조로만 견주면 옛 참조가 아무것도
|
|
9
|
+
* 못 찾았다(수정 저장 → 재조회 → 같은 상세에서 '방문' 을 누르면 예정이 안 지워져 방문예정·방문완료 양쪽에 떴다).
|
|
10
|
+
*/
|
|
11
|
+
export declare function isSameEditableItem<T>(a: T, b: T): boolean;
|
|
6
12
|
export declare function useEditableList<T>(emptyItem: T, initialList?: T[]): {
|
|
7
13
|
list: T[];
|
|
8
14
|
setList: import("react").Dispatch<import("react").SetStateAction<T[]>>;
|
|
@@ -5,6 +5,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
5
5
|
*
|
|
6
6
|
* @public
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* 같은 항목인가 — 같은 참조이거나, **둘 다 비지 않은 같은 id 를 가지면** 같다(빈 id 는 새로 만든 로컬 항목이라 견주지 않는다).
|
|
10
|
+
* 서버에서 목록을 다시 받으면 항목이 모두 새 객체가 되는데, 고른 항목·지울 항목을 참조로만 견주면 옛 참조가 아무것도
|
|
11
|
+
* 못 찾았다(수정 저장 → 재조회 → 같은 상세에서 '방문' 을 누르면 예정이 안 지워져 방문예정·방문완료 양쪽에 떴다).
|
|
12
|
+
*/
|
|
13
|
+
export function isSameEditableItem(a, b) {
|
|
14
|
+
if (a === b)
|
|
15
|
+
return true;
|
|
16
|
+
const ia = a?.id;
|
|
17
|
+
const ib = b?.id;
|
|
18
|
+
return ia != null && ia !== '' && ia === ib;
|
|
19
|
+
}
|
|
8
20
|
export function useEditableList(emptyItem, initialList = []) {
|
|
9
21
|
const [list, setList] = useState(initialList);
|
|
10
22
|
const [selected, setSelected] = useState(null);
|
|
@@ -42,7 +54,14 @@ export function useEditableList(emptyItem, initialList = []) {
|
|
|
42
54
|
return;
|
|
43
55
|
prevInitialRef.current = initialList;
|
|
44
56
|
setList(initialList);
|
|
57
|
+
// 고른 항목도 새 목록의 같은 항목(id)으로 옮긴다 — 옛 참조로 남으면 지우기·수정 대상 찾기가 헛돈다
|
|
58
|
+
setSelected(prev => (prev == null ? prev : initialList.find(v => isSameEditableItem(v, prev)) ?? prev));
|
|
45
59
|
}, [initialList]);
|
|
60
|
+
/* 편집 중이 아닐 때는 편집 폼을 고른 항목에 맞춘다 — 취소하거나(서버 거절로) 되돌려도 폼이 저장 안 된 값을 들고 있어,
|
|
61
|
+
상세 화면에 거절된 값이 보이고 다시 '수정하기' 를 누르면 그 값에서 시작했다. */
|
|
62
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
63
|
+
useEffect(() => { if (!isEditing && selected != null)
|
|
64
|
+
setEditForm({ ...selected }); }, [isEditing, selected]);
|
|
46
65
|
const openDetail = useCallback((item) => {
|
|
47
66
|
setSelected(item);
|
|
48
67
|
setIsEditing(false);
|
|
@@ -56,7 +75,7 @@ export function useEditableList(emptyItem, initialList = []) {
|
|
|
56
75
|
setIsEditing(false);
|
|
57
76
|
}, []);
|
|
58
77
|
const remove = useCallback((item) => {
|
|
59
|
-
setList(prev => prev.filter(v => v
|
|
78
|
+
setList(prev => prev.filter(v => !isSameEditableItem(v, item)));
|
|
60
79
|
setSelected(null);
|
|
61
80
|
}, []);
|
|
62
81
|
return {
|
|
@@ -43,6 +43,10 @@ export function useTablePaging(rows, resetOn, opts) {
|
|
|
43
43
|
대부분의 경우 빈 표가 된다 */
|
|
44
44
|
const setPageSize = useCallback((v) => { setPageSizeRaw(v); setPage(1); }, []);
|
|
45
45
|
const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
|
|
46
|
+
/* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
|
|
47
|
+
5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
|
|
48
|
+
if (page > total)
|
|
49
|
+
setPage(total);
|
|
46
50
|
const 이쪽 = Math.min(page, total);
|
|
47
51
|
const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
|
|
48
52
|
return {
|