@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
|
@@ -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
|
}
|
|
@@ -54,10 +54,13 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
54
54
|
return context;
|
|
55
55
|
return "";
|
|
56
56
|
}, [context]);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
/* 문장이 바뀌면 **그 렌더에서** 가린다 — 타이머로 한 틱 뒤에 가리면, 앞 문제에서 펼쳐 둔 상자에 다음 문제 문장이
|
|
58
|
+
잠깐 드러났다(정답 문장이 번쩍). 렌더 중 상태 조정은 React 가 권하는 꼴이다. */
|
|
59
|
+
const [보던문장, set보던문장] = useState(content);
|
|
60
|
+
if (보던문장 !== content) {
|
|
61
|
+
set보던문장(content);
|
|
62
|
+
setLocalStatus("disabled");
|
|
63
|
+
}
|
|
61
64
|
const displayStatus = state === "disabled" ? localStatus : state;
|
|
62
65
|
const stateClass = displayStatus === "disabled"
|
|
63
66
|
? "training-text-box--disabled"
|
|
@@ -87,5 +87,8 @@ export default function TrainingStepTitle({ title, children, className = "", sta
|
|
|
87
87
|
setPreviewContent(null);
|
|
88
88
|
}
|
|
89
89
|
}, [onPreviewTextBtn, previewText, showPreview]);
|
|
90
|
-
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive
|
|
90
|
+
return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive,
|
|
91
|
+
// 재생 중에는 native disabled 를 달지 않는다 — 키보드로 누른 단추에 disabled 가 붙으면 초점이 body 로 빠졌다.
|
|
92
|
+
// onDisabledClick 을 주면 Button 은 aria-disabled 로만 막고 클릭은 여기서 버린다.
|
|
93
|
+
onDisabledClick: playAllActive && state !== 'disabled' ? () => { } : undefined })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
|
|
91
94
|
}
|
|
@@ -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();
|
|
@@ -93,13 +93,19 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
|
|
|
93
93
|
const customFooter = useMemo(() => (renderFooter ? renderFooter({ print: handlePrint, openNewWindow }) : undefined), [renderFooter, handlePrint, openNewWindow]);
|
|
94
94
|
// 메뉴 '인쇄하기' 진입: 팝업이 열리고 표가 렌더된 직후 인쇄를 1회 자동 시작.
|
|
95
95
|
// 짧은 지연은 WordTable 레이아웃 확정용 — 인쇄 iframe 쪽이 이미지 로드는 별도로 기다린다(wordTablePrint).
|
|
96
|
+
/* '했다' 는 **타이머가 실제로 울렸을 때** 적는다 — 걸 때 적으면 StrictMode 의 두 번 실행이나 100ms 안에 인쇄 설정이
|
|
97
|
+
바뀌어(handlePrint 가 새로 만들어져) 앞 타이머가 걷힌 뒤 다시 걸지 못해, 인쇄가 한 번도 안 떴다. 부르는 것은 최신 print. */
|
|
96
98
|
const autoPrintDone = useRef(false);
|
|
99
|
+
const printRef = useRef(handlePrint);
|
|
100
|
+
useEffect(() => { printRef.current = handlePrint; }, [handlePrint]);
|
|
97
101
|
useEffect(() => {
|
|
98
102
|
if (!autoPrint || autoPrintDone.current)
|
|
99
103
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
const id = window.setTimeout(() => {
|
|
105
|
+
autoPrintDone.current = true;
|
|
106
|
+
printRef.current();
|
|
107
|
+
}, 100);
|
|
102
108
|
return () => window.clearTimeout(id);
|
|
103
|
-
}, [autoPrint
|
|
109
|
+
}, [autoPrint]);
|
|
104
110
|
return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
|
|
105
111
|
}
|
|
@@ -41,8 +41,10 @@ export interface TrainingBlockButtonProps {
|
|
|
41
41
|
* - `small`: 120×120px 축소 카드 (행 펼치기 영역 등 좁은 공간용)
|
|
42
42
|
* - `list`: 너비 200px · 최소 높이 68px 가로형 항목
|
|
43
43
|
* - `compact`: 툴팁 등 좁은 공간용 소형 리스트 항목 (전체 너비 × 높이 40px, 작은 아이콘)
|
|
44
|
+
* - `row`: 전체 너비 가로 행 — [아이콘][유형·제목][문항수]. 미리 듣기 단추는 없다.
|
|
45
|
+
* 좁은 화면에서 정사각형 카드 대신 쓴다(카드는 폭이 줄면 내용이 눌린다). 유형 줄은 `showTypeLabel` 일 때.
|
|
44
46
|
*/
|
|
45
|
-
size?: 'square' | 'small' | 'list' | 'compact';
|
|
47
|
+
size?: 'square' | 'small' | 'list' | 'compact' | 'row';
|
|
46
48
|
renderPopup?: (props: TrainingBlockButtonPopupProps) => ReactNode;
|
|
47
49
|
completedIds?: Set<string>;
|
|
48
50
|
/** 선택 상태 ID Set. 포함된 항목(완료가 아닌 것)에 체크 배지를 표시 — 완료와 같은 배지이고, `hideCheckBadge` 면 숨는다. */
|
|
@@ -57,8 +57,15 @@ export function summarizeMisses(records) {
|
|
|
57
57
|
cur.lastSelected = r.selected;
|
|
58
58
|
/* 세트는 **최근 것이 앞**이어야 한다 — 다음 훈련을 짤 때 옛 세트를 먼저 집으면
|
|
59
59
|
그 사이 세트가 바뀌었을 수 있다. 시간순으로 훑고 있으니 앞에 넣는다. */
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/* 이미 있던 세트를 다시 풀었으면 **빼서 다시 앞으로** — 건너뛰기만 하면 A→B→A 순서에서 [B, A] 로 남아
|
|
61
|
+
복습이 가장 최근 세트(A) 대신 옛 세트(B)로 짜였다. */
|
|
62
|
+
if (r.setId) {
|
|
63
|
+
const at = cur.setIds.indexOf(r.setId);
|
|
64
|
+
if (at > 0)
|
|
65
|
+
cur.setIds.splice(at, 1);
|
|
66
|
+
if (at !== 0)
|
|
67
|
+
cur.setIds.unshift(r.setId);
|
|
68
|
+
}
|
|
62
69
|
모음.set(key, cur);
|
|
63
70
|
}
|
|
64
71
|
const items = [...모음.entries()].map(([key, v]) => ({
|
|
@@ -20,7 +20,7 @@ import { formatYmd, getTodayKst } from '../../utils/date';
|
|
|
20
20
|
import { getSavedTables } from '../../utils/savedWordTables';
|
|
21
21
|
import { devWarnOnce } from '../../utils/devWarn';
|
|
22
22
|
import { useWordTablePrint } from './useWordTablePrint';
|
|
23
|
-
import { CELL_COUNTS } from './wordTableBlockTypes';
|
|
23
|
+
import { CELL_COUNTS, ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
|
|
24
24
|
// ── 컴포넌트 ───────────────────────────────────────────────
|
|
25
25
|
/** @public */
|
|
26
26
|
export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }) {
|
|
@@ -191,7 +191,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
191
191
|
setDisplayCellCount(parseInt(value));
|
|
192
192
|
}
|
|
193
193
|
else if (sectionLabel === t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')) {
|
|
194
|
-
|
|
194
|
+
/* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
|
|
195
|
+
선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
|
|
196
|
+
const n = parseInt(value);
|
|
197
|
+
setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
|
|
195
198
|
}
|
|
196
199
|
else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
|
|
197
200
|
setPageNumberPosition(parsePageNumberPosition(value));
|
|
@@ -233,7 +236,8 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
233
236
|
const saveAsTrimmed = saveAsName.trim();
|
|
234
237
|
const saveAsCollides = saveAsTrimmed !== '' && namesLower.includes(saveAsTrimmed.toLowerCase());
|
|
235
238
|
const openSaveAsPopup = () => {
|
|
236
|
-
|
|
239
|
+
// 복사본 이름도 사전을 탄다 — 박혀 있어 영어 화면에 'My table (복사)' 가 채워졌다
|
|
240
|
+
setSaveAsName(trimmedName ? t('wordTable.save.copyName', { name: trimmedName, defaultValue: '{{name}} (복사)' }) : '');
|
|
237
241
|
setShowSaveAsPopup(true);
|
|
238
242
|
};
|
|
239
243
|
const handleSaveAsConfirm = () => {
|
|
@@ -16,6 +16,9 @@ export default function WordTableButton({ items, name, description, createdAt, p
|
|
|
16
16
|
const renaming = titleNode != null;
|
|
17
17
|
const hasStoreFooter = publisherName != null || publishedAt != null || likeCount != null || downloadCount != null;
|
|
18
18
|
const handleKey = (e) => {
|
|
19
|
+
// 카드 안 단추('더보기'·게시자)에서 누른 키는 그 단추 몫이다 — 가로채면 카드가 열리고 단추 클릭은 막혔다(pressable 과 같은 규칙)
|
|
20
|
+
if (e.target !== e.currentTarget)
|
|
21
|
+
return;
|
|
19
22
|
if (!renaming && (e.key === 'Enter' || e.key === ' ')) {
|
|
20
23
|
e.preventDefault();
|
|
21
24
|
onClick?.();
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useMemo, useState, useCallback, useEffect } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { chunkItems } from '../wordTableLayout';
|
|
5
|
+
import { ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
|
|
5
6
|
import { getImageResolver, checkImageExists } from '../../utils/imageConfig';
|
|
6
7
|
import { buildCompositionSection, buildOrientationSection, buildBorderSection, buildCutLineSection, buildDivLineSection, buildItemsPerPageSection, buildPageNumberSection, buildTableSettingsSelected, applyTableSettingsSelect, PAGE_NUM_POSITION_VALUE, parsePageNumberPosition, } from './wordTableSettings';
|
|
7
8
|
/**
|
|
@@ -68,7 +69,10 @@ export function useWordTableDisplaySettings(items) {
|
|
|
68
69
|
if (applyTableSettingsSelect(t, value, sectionLabel, { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }))
|
|
69
70
|
return;
|
|
70
71
|
if (sectionLabel === t('wordTable.filter.itemsPerPagePair', '페이지당 대립쌍 수') || sectionLabel === t('wordTable.filter.itemsPerPageWord', '페이지당 단어 수')) {
|
|
71
|
-
|
|
72
|
+
/* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
|
|
73
|
+
선택지([20, 8, 4, 2, 1])에 없어 켜진 칸이 없는데 표는 10개씩 나뉘었다. */
|
|
74
|
+
const n = parseInt(value);
|
|
75
|
+
setItemsPerPageSetting(ITEMS_PER_PAGE_OPTIONS.includes(n) ? n : Number.POSITIVE_INFINITY);
|
|
72
76
|
}
|
|
73
77
|
else if (sectionLabel === t('wordTable.filter.pageNumber', '페이지 번호')) {
|
|
74
78
|
setPageNumberPosition(parsePageNumberPosition(value));
|
|
@@ -62,12 +62,17 @@ export function tableTrends(records) {
|
|
|
62
62
|
}
|
|
63
63
|
const out = [];
|
|
64
64
|
for (const v of 모음.values()) {
|
|
65
|
-
|
|
65
|
+
/* 매긴 칸이 하나도 없는 기록은 흐름에서 뺀다 — 정답률 0%로 넣으면 [0, 0.5] 처럼 나아진 적 없는 표가 +50%로 읽혔다
|
|
66
|
+
(summarize 는 같은 경우를 null 로 따로 둔다). 모두 비었으면 그 표는 흐름이 없다. */
|
|
67
|
+
const 매긴수 = (r) => Object.values(r.marks).filter(m => m === 'o' || m === 'triangle' || m === 'x').length;
|
|
68
|
+
const rows = v.rows.filter(r => 매긴수(r) > 0).sort((a, b) => a.scoredAt.localeCompare(b.scoredAt));
|
|
69
|
+
if (rows.length === 0)
|
|
70
|
+
continue;
|
|
66
71
|
const rates = rows.map(r => {
|
|
67
72
|
const vals = Object.values(r.marks);
|
|
68
73
|
const o = vals.filter(m => m === 'o').length;
|
|
69
74
|
const tri = vals.filter(m => m === 'triangle').length;
|
|
70
|
-
return 점수(o, tri,
|
|
75
|
+
return 점수(o, tri, 매긴수(r));
|
|
71
76
|
});
|
|
72
77
|
out.push({
|
|
73
78
|
tableName: v.tableName,
|
|
@@ -17,6 +17,8 @@ function escapeHtml(s) {
|
|
|
17
17
|
export function sanitizeFileName(name) {
|
|
18
18
|
return name.replace(/[\\/:*?"<>|]/g, '_').trim();
|
|
19
19
|
}
|
|
20
|
+
/** 아직 되돌리지 못한 탭 제목 되돌리기(인쇄가 끝나기 전 다음 인쇄가 올 때) */
|
|
21
|
+
let 제목되돌리기 = null;
|
|
20
22
|
/**
|
|
21
23
|
* 캡처한 콘텐츠 엘리먼트들을 화면 밖 숨김 iframe에서 인쇄한다. 전역 @media print(모든
|
|
22
24
|
* .word-table 노출)나 팝업 차단의 간섭 없이 넘긴 엘리먼트만 격리 인쇄한다. WordTableBlock의
|
|
@@ -28,10 +30,21 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
|
|
|
28
30
|
return;
|
|
29
31
|
const safeFileName = fileName ? sanitizeFileName(fileName) : '';
|
|
30
32
|
// "PDF로 저장" 기본 파일명은 iframe이 아니라 최상위 탭(document)의 title에서 온다.
|
|
33
|
+
/* 탭 제목은 **어느 쪽 afterprint 가 와도** 되돌린다 — 브라우저에 따라 iframe 인쇄의 print 이벤트가 그 프레임에만 가서,
|
|
34
|
+
최상위에만 걸면 제목이 표 이름으로 남았고, 다음 인쇄가 그 표 이름을 '원래 제목' 으로 잡아 영영 안 돌아왔다.
|
|
35
|
+
되돌리지 못한 앞 인쇄가 있으면 먼저 되돌린 뒤 원래 제목을 잡는다. */
|
|
36
|
+
제목되돌리기?.();
|
|
37
|
+
let restore = null;
|
|
31
38
|
if (safeFileName) {
|
|
32
39
|
const prevTitle = document.title;
|
|
33
40
|
document.title = safeFileName;
|
|
34
|
-
|
|
41
|
+
restore = () => {
|
|
42
|
+
document.title = prevTitle;
|
|
43
|
+
window.removeEventListener('afterprint', restore);
|
|
44
|
+
if (제목되돌리기 === restore)
|
|
45
|
+
제목되돌리기 = null;
|
|
46
|
+
};
|
|
47
|
+
제목되돌리기 = restore;
|
|
35
48
|
window.addEventListener('afterprint', restore);
|
|
36
49
|
}
|
|
37
50
|
const iframe = document.createElement('iframe');
|
|
@@ -45,7 +58,7 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
|
|
|
45
58
|
doc.open();
|
|
46
59
|
doc.write(captureHtml(elements, paperW, paperH, true, safeFileName, header));
|
|
47
60
|
doc.close();
|
|
48
|
-
iframe.contentWindow?.addEventListener('afterprint', () => setTimeout(() => iframe.remove(), 0));
|
|
61
|
+
iframe.contentWindow?.addEventListener('afterprint', () => { restore?.(); setTimeout(() => iframe.remove(), 0); });
|
|
49
62
|
}
|
|
50
63
|
// 인쇄 시 PDF로 저장할 때 OS가 제안하는 기본 파일명은 문서 <title>에서 온다.
|
|
51
64
|
export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '', header = '') {
|
|
@@ -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 = ['첫째', '둘째', '셋째', '넷째', '다섯째', '여섯째', '일곱째'];
|
|
@@ -87,6 +87,12 @@ export interface ButtonProps {
|
|
|
87
87
|
title?: string;
|
|
88
88
|
items: string[];
|
|
89
89
|
} | null;
|
|
90
|
+
/**
|
|
91
|
+
* 누르는 단추가 아니라 **툴팁을 보여 주는 칩** 이다 — 커서를 손가락이 아닌 기본 화살표로 둔다.
|
|
92
|
+
* 모양(바탕·hover)과 포커스·툴팁은 그대로다. `onClick` 없이 `showTooltipInfo`·`tooltipDescription` 만 다는
|
|
93
|
+
* 자리(점수 칩 등)에 쓴다. 막힌 단추(`disabled`)와는 다르다 — 흐려지지 않는다.
|
|
94
|
+
*/
|
|
95
|
+
tooltipOnly?: boolean;
|
|
90
96
|
iconSize?: 'small' | 'medium' | 'large';
|
|
91
97
|
iconColor?: string;
|
|
92
98
|
/**
|
|
@@ -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('');
|
|
@@ -29,22 +29,20 @@ export function useCalendarMonth(value) {
|
|
|
29
29
|
setViewM(next.m);
|
|
30
30
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
31
|
}, [value]);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}, [viewM]);
|
|
48
|
-
const setViewYM = useCallback((y, m) => { setViewY(y); setViewM(m); }, []);
|
|
32
|
+
/* 달 넘김은 **지금 가리키는 연·월 하나** 에서 센다 — 갈래는 렌더 때의 viewM 으로, 갱신은 함수형으로 나눠 하던 때는
|
|
33
|
+
한 핸들러에서 두 번 부르면(2월에서 prevMonth 두 번) (2026, -1) 이 됐다. 부른 즉시 ref 도 옮겨 둔다. */
|
|
34
|
+
const ymRef = useRef({ y: viewY, m: viewM });
|
|
35
|
+
useEffect(() => { ymRef.current = { y: viewY, m: viewM }; }, [viewY, viewM]);
|
|
36
|
+
const moveMonth = useCallback((d) => {
|
|
37
|
+
const total = ymRef.current.y * 12 + ymRef.current.m + d;
|
|
38
|
+
const y = Math.floor(total / 12);
|
|
39
|
+
const m = total - y * 12;
|
|
40
|
+
ymRef.current = { y, m };
|
|
41
|
+
setViewY(y);
|
|
42
|
+
setViewM(m);
|
|
43
|
+
}, []);
|
|
44
|
+
const prevMonth = useCallback(() => moveMonth(-1), [moveMonth]);
|
|
45
|
+
const nextMonth = useCallback(() => moveMonth(1), [moveMonth]);
|
|
46
|
+
const setViewYM = useCallback((y, m) => { ymRef.current = { y, m }; setViewY(y); setViewM(m); }, []);
|
|
49
47
|
return { viewY, viewM, prevMonth, nextMonth, setViewYM };
|
|
50
48
|
}
|
|
@@ -83,8 +83,13 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
|
|
|
83
83
|
// 이미 보이는 중 → 텍스트만 업데이트, 애니메이션 재실행 없음
|
|
84
84
|
if (showingRef.current) {
|
|
85
85
|
if (DURATION_MS > 0 && !hideTimerRef.current) {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
/* 멈춘 채(마우스·포커스가 올라가 있는 채) 문구가 바뀌면 시계를 새로 걸지 않는다 — 전에는 멈춤을 풀고 걸어
|
|
87
|
+
읽고 있는 알림이 손 밑에서 사라졌고, 떠날 때의 resume 은 멈춤이 이미 풀려 아무것도 안 했다.
|
|
88
|
+
새 문구의 시간은 **처음부터** 남겨 두고 resume 이 그만큼 돌린다. */
|
|
89
|
+
if (멈춤Ref.current)
|
|
90
|
+
남은Ref.current = DURATION_MS;
|
|
91
|
+
else
|
|
92
|
+
사라짐걸기.current(DURATION_MS);
|
|
88
93
|
}
|
|
89
94
|
lastMessageRef.current = message;
|
|
90
95
|
/* 여기서 건 사라짐 타이머도 떠날 때 걷는다 — 빈 정리 함수라 문구를 바꾼 뒤 언마운트하면 타이머가 살아
|
|
@@ -3,13 +3,22 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
import i18n, { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../i18n';
|
|
5
5
|
import { I18nContext } from './I18nContext';
|
|
6
|
+
function 지원코드(locale) {
|
|
7
|
+
const 있나 = (c) => SUPPORTED_LOCALES.some(l => l.code === c);
|
|
8
|
+
if (있나(locale))
|
|
9
|
+
return locale;
|
|
10
|
+
const 기본 = locale.split(/[-_]/)[0]?.toLowerCase() ?? '';
|
|
11
|
+
return 있나(기본) ? 기본 : DEFAULT_LOCALE;
|
|
12
|
+
}
|
|
6
13
|
/** @public */
|
|
7
14
|
export function I18nProvider({ locale = DEFAULT_LOCALE, children }) {
|
|
15
|
+
/* 지역이 붙은 코드('en-US'·'ko_KR')는 기본 언어로 읽는다 — 전에는 목록에 없다고 기본(ko)으로 떨어져 영어를 달라 했는데
|
|
16
|
+
한국어가 나오고, context 의 locale 은 'en-US' 로 남아 둘이 갈렸다. context 에도 실제로 건 코드를 넣는다. */
|
|
17
|
+
const code = 지원코드(locale);
|
|
8
18
|
useEffect(() => {
|
|
9
|
-
const code = SUPPORTED_LOCALES.find(l => l.code === locale) ? locale : DEFAULT_LOCALE;
|
|
10
19
|
if (i18n.language !== code) {
|
|
11
20
|
i18n.changeLanguage(code);
|
|
12
21
|
}
|
|
13
|
-
}, [
|
|
14
|
-
return (_jsx(I18nContext.Provider, { value: { locale, supportedLocales: SUPPORTED_LOCALES }, children: children }));
|
|
22
|
+
}, [code]);
|
|
23
|
+
return (_jsx(I18nContext.Provider, { value: { locale: code, supportedLocales: SUPPORTED_LOCALES }, children: children }));
|
|
15
24
|
}
|
|
@@ -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 {
|
|
@@ -25,6 +25,8 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
25
25
|
const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
|
|
26
26
|
useEffect(() => () => clearTimers(), []);
|
|
27
27
|
const answerId = queue[index]?.id ?? null;
|
|
28
|
+
/** 이미 고른 문항 번호 — 새 문항이 대기에 들어가면 풀린다(playQuestion) */
|
|
29
|
+
const 고름잠금 = useRef(null);
|
|
28
30
|
// ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
|
|
29
31
|
const runPlayback = useCallback(() => {
|
|
30
32
|
clearTimers();
|
|
@@ -47,6 +49,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
47
49
|
onDoneRef.current?.({ score: scoreRef.current, total: q.length });
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
52
|
+
고름잠금.current = null;
|
|
50
53
|
setIndex(i);
|
|
51
54
|
setSelectedId(null);
|
|
52
55
|
setPhase('playing');
|
|
@@ -82,17 +85,25 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
82
85
|
}, [setScoreTracked]);
|
|
83
86
|
// autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
|
|
84
87
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
88
|
+
/* 배치가 바뀌었는지는 **스피커 id 들**로 본다 — 개수만 보면 수가 같은 다른 배치(HORIZON_7 ↔ PRESET_27 가로)로 바꿔도
|
|
89
|
+
초기화되지 않아 옛 문제 줄이 남고 정답 id 가 새 배치에 없어 무엇을 골라도 오답이었다. */
|
|
90
|
+
const 배치열쇠 = speakers.map((sp) => sp.id).join('|');
|
|
85
91
|
useEffect(() => {
|
|
86
92
|
if (autoStart)
|
|
87
93
|
start();
|
|
88
94
|
else
|
|
89
95
|
reset();
|
|
96
|
+
// 배치·모드가 바뀌거나 떠날 때 앞 판의 예약을 걷는다
|
|
97
|
+
return () => clearTimers();
|
|
90
98
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
-
}, [mode,
|
|
99
|
+
}, [mode, 배치열쇠, autoStart]);
|
|
92
100
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
93
101
|
const onSelectSpeaker = useCallback((id) => {
|
|
94
|
-
|
|
102
|
+
/* 같은 대기에서 **한 번만** 받는다 — 다시 그려지기 전에 같은 핸들러가 두 번 불리면(렌더 시점의 phase 만 보면) 점수가 +2,
|
|
103
|
+
다음 문제가 두 번 예약됐다. 고르는 순간 ref 로 닫는다. */
|
|
104
|
+
if (phase !== 'waiting' || mode === 'playback' || 고름잠금.current === index)
|
|
95
105
|
return;
|
|
106
|
+
고름잠금.current = index;
|
|
96
107
|
setSelectedId(id);
|
|
97
108
|
const correct = id === queue[index]?.id;
|
|
98
109
|
if (correct)
|