@ihabdevteam/core 0.149.0 → 0.151.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 +60 -0
- package/dist/components.cjs.js +199 -186
- package/dist/components.esm.css +5 -5
- package/dist/hooks.cjs.js +5 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core-tokens.css +1 -1
- package/dist/ihabdevteam-core.cjs.js +200 -187
- package/dist/ihabdevteam-core.css +1 -1
- package/dist/ihabdevteam-core.d.ts +407 -69
- package/dist/packages/core/src/components/Accordion.d.ts +6 -0
- package/dist/packages/core/src/components/Accordion.js +5 -2
- package/dist/packages/core/src/components/Alert.d.ts +7 -1
- package/dist/packages/core/src/components/Alert.js +12 -3
- package/dist/packages/core/src/components/BarChart.js +7 -2
- package/dist/packages/core/src/components/Board.d.ts +5 -2
- package/dist/packages/core/src/components/Board.js +9 -3
- package/dist/packages/core/src/components/BottomNav.js +5 -4
- package/dist/packages/core/src/components/Breadcrumb.css +9 -0
- package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
- package/dist/packages/core/src/components/Breadcrumb.js +8 -2
- package/dist/packages/core/src/components/BulkBar.css +2 -0
- package/dist/packages/core/src/components/BulkBar.js +21 -2
- package/dist/packages/core/src/components/Button.d.ts +12 -2
- package/dist/packages/core/src/components/Button.js +70 -13
- package/dist/packages/core/src/components/Calendar.d.ts +6 -1
- package/dist/packages/core/src/components/Calendar.js +3 -3
- package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
- package/dist/packages/core/src/components/DatePicker.js +25 -15
- package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
- package/dist/packages/core/src/components/Dropdown.js +7 -5
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
- package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
- package/dist/packages/core/src/components/EmptyState.js +4 -1
- package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
- package/dist/packages/core/src/components/ErrorState.js +3 -1
- package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
- package/dist/packages/core/src/components/FileUpload.js +8 -2
- package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
- package/dist/packages/core/src/components/Header.js +8 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
- package/dist/packages/core/src/components/Icon.d.ts +2 -1
- package/dist/packages/core/src/components/Icon.js +4 -1
- package/dist/packages/core/src/components/Loader.css +28 -0
- package/dist/packages/core/src/components/Loader.d.ts +5 -1
- package/dist/packages/core/src/components/Loader.js +4 -2
- package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
- package/dist/packages/core/src/components/NoticeBox.js +15 -6
- package/dist/packages/core/src/components/PanelRow.js +2 -1
- package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
- package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
- package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
- package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
- package/dist/packages/core/src/components/Popup/Popup.js +6 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
- package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
- package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
- package/dist/packages/core/src/components/Rating.d.ts +8 -2
- package/dist/packages/core/src/components/Rating.js +22 -8
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
- package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
- package/dist/packages/core/src/components/RingChart.d.ts +1 -1
- package/dist/packages/core/src/components/RingChart.js +1 -1
- package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
- package/dist/packages/core/src/components/SegmentedControl.js +9 -1
- package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
- package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
- package/dist/packages/core/src/components/SideNav.js +8 -2
- package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
- package/dist/packages/core/src/components/SummaryStrip.js +2 -2
- package/dist/packages/core/src/components/Table.d.ts +1 -1
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/Tabs.d.ts +1 -0
- package/dist/packages/core/src/components/Tabs.js +4 -1
- package/dist/packages/core/src/components/TextField.css +5 -6
- package/dist/packages/core/src/components/TextField.js +46 -3
- package/dist/packages/core/src/components/Toast.js +16 -2
- package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
- package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
- package/dist/packages/core/src/components/TopBanner.js +66 -8
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +29 -11
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
- package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
- package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
- package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
- package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
- package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
- package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
- package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
- package/dist/packages/core/src/components/UpdateAlert.css +4 -2
- package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
- package/dist/packages/core/src/components/UpdateAlert.js +21 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
- package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
- package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
- package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
- package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
- package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
- package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
- package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
- package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
- package/dist/packages/core/src/components/WordTable.css +10 -0
- package/dist/packages/core/src/components/WordTable.js +39 -26
- package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
- package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
- package/dist/packages/core/src/components/updateAlertStack.js +68 -0
- package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
- package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
- package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
- package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
- package/dist/packages/core/src/components/wordTableLayout.js +6 -0
- package/dist/packages/core/src/components.d.ts +2 -1
- package/dist/packages/core/src/components.js +1 -1
- package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
- package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
- package/dist/packages/core/src/hooks/focusReturn.js +49 -0
- package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
- package/dist/packages/core/src/hooks/touchToggle.js +114 -0
- package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
- package/dist/packages/core/src/hooks/useIcon.js +29 -5
- package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
- package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
- package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
- package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
- package/dist/packages/core/src/hooks/useRelativeDay.d.ts +2 -2
- package/dist/packages/core/src/hooks/useRelativeDay.js +12 -5
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
- package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
- package/dist/packages/core/src/i18n/index.js +9 -1
- package/dist/packages/core/src/i18n/locales/en.json +24 -2
- package/dist/packages/core/src/i18n/locales/ko.json +24 -2
- package/dist/packages/core/src/tokens.css +5 -0
- package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
- package/dist/packages/core/src/utils/csv.d.ts +7 -2
- package/dist/packages/core/src/utils/csv.js +12 -5
- package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
- package/dist/packages/core/src/utils/date.d.ts +8 -0
- package/dist/packages/core/src/utils/date.js +44 -10
- package/dist/packages/core/src/utils/format.d.ts +1 -1
- package/dist/packages/core/src/utils/format.js +6 -7
- package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
- package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
- package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
- package/dist/packages/core/src/utils/safeHref.js +24 -0
- package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
- package/dist/packages/core/src/utils/savedWordTables.js +27 -19
- package/dist/packages/core/src/utils/textSort.d.ts +9 -0
- package/dist/packages/core/src/utils/textSort.js +37 -1
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
- package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
- package/dist/packages/core/src/utils/wordContrast.js +8 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
- package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
- package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
- package/dist/packages/core/src/utils/wornDate.js +15 -0
- 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
|
@@ -42,8 +42,32 @@ export declare function useTablePaging<T>(rows: readonly T[], resetOn?: unknown,
|
|
|
42
42
|
};
|
|
43
43
|
setPage: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
44
44
|
};
|
|
45
|
-
/**
|
|
46
|
-
export
|
|
45
|
+
/** `useTableSort` 의 선택 인자. @public */
|
|
46
|
+
export interface TableSortOptions<K extends string> {
|
|
47
|
+
/** 머리를 눌러 정렬이 바뀔 때 바뀐 정렬로 불린다 — 쪽을 따로 들고 있는 표가 첫 쪽으로 돌아가는 자리 등. */
|
|
48
|
+
onSortChange?: (sort: {
|
|
49
|
+
key: K;
|
|
50
|
+
asc: boolean;
|
|
51
|
+
}) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 머리행 정렬 상태 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
55
|
+
*
|
|
56
|
+
* **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
|
|
57
|
+
* `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
|
|
58
|
+
*
|
|
59
|
+
* `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
|
|
60
|
+
* 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
|
|
64
|
+
* const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
|
|
65
|
+
* const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
|
|
66
|
+
* <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
|
|
67
|
+
*
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
export declare function useTableSort<K extends string>(opts?: TableSortOptions<K>): {
|
|
47
71
|
정렬: {
|
|
48
72
|
key: K;
|
|
49
73
|
asc: boolean;
|
|
@@ -53,4 +77,5 @@ export declare function useTableSort<K extends string>(): {
|
|
|
53
77
|
sortDir: "desc" | "asc" | null;
|
|
54
78
|
onClick: () => void;
|
|
55
79
|
};
|
|
80
|
+
sortRows: <R>(rows: readonly R[], 값: (row: R, key: K) => unknown) => readonly R[];
|
|
56
81
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { VAP_PAGE_SIZE_OPTIONS } from './useVisitAllPanel';
|
|
5
|
+
import { 칸순빈값뒤 } from '../utils/textSort';
|
|
5
6
|
/**
|
|
6
7
|
* 표의 쪽넘김 — 개수 보기 · 쪽 · 이번 쪽 줄.
|
|
7
8
|
*
|
|
@@ -60,11 +61,35 @@ export function useTablePaging(rows, resetOn, opts) {
|
|
|
60
61
|
setPage,
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* 머리행 정렬 상태 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
66
|
+
*
|
|
67
|
+
* **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
|
|
68
|
+
* `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
|
|
69
|
+
*
|
|
70
|
+
* `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
|
|
71
|
+
* 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
|
|
75
|
+
* const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
|
|
76
|
+
* const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
|
|
77
|
+
* <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
|
|
78
|
+
*
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
81
|
+
export function useTableSort(opts) {
|
|
65
82
|
const [정렬, 정렬바꾸기] = useState(null);
|
|
83
|
+
/* 다음 정렬은 **ref 에서** 계산해 알린다 — 갱신 함수 안에서 부르면 StrictMode 가 두 번 부른다 */
|
|
84
|
+
const 지금정렬 = useRef(정렬);
|
|
85
|
+
const 알림 = useRef(opts?.onSortChange);
|
|
86
|
+
알림.current = opts?.onSortChange;
|
|
66
87
|
const 누름 = useCallback((key) => {
|
|
67
|
-
|
|
88
|
+
const 앞 = 지금정렬.current;
|
|
89
|
+
const 다음 = 앞?.key === key ? { key, asc: !앞.asc } : { key, asc: true };
|
|
90
|
+
지금정렬.current = 다음;
|
|
91
|
+
정렬바꾸기(다음);
|
|
92
|
+
알림.current?.(다음);
|
|
68
93
|
}, []);
|
|
69
94
|
/** Table 의 field 하나에 붙일 것 — 라벨과 함께 쓴다 */
|
|
70
95
|
const 칸 = useCallback((label, key) => ({
|
|
@@ -72,6 +97,13 @@ export function useTableSort() {
|
|
|
72
97
|
sortDir: 정렬?.key === key ? (정렬.asc ? 'asc' : 'desc') : null,
|
|
73
98
|
onClick: () => 누름(key),
|
|
74
99
|
}), [정렬, 누름]);
|
|
100
|
+
/** 지금 정렬로 세운 새 배열(정렬 없으면 받은 배열 그대로). 빈 값은 오름·내림 모두 맨 뒤. */
|
|
101
|
+
const sortRows = useCallback((rows, 값) => {
|
|
102
|
+
if (!정렬)
|
|
103
|
+
return rows;
|
|
104
|
+
const { key, asc } = 정렬;
|
|
105
|
+
return [...rows].sort((a, b) => 칸순빈값뒤(값(a, key), 값(b, key), asc));
|
|
106
|
+
}, [정렬]);
|
|
75
107
|
/* 쪽넘김에 그대로 건넨다 — 차례가 바뀌면 첫 쪽으로 돌아가게 */
|
|
76
|
-
return { 정렬,
|
|
108
|
+
return { 정렬, 칸, sortRows };
|
|
77
109
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { TFunction } from 'i18next';
|
|
3
3
|
import { type FilterPanelSection } from '../types/filterPanel';
|
|
4
|
+
export { toWordAttributeFilterCodes, wordAttributeFilterFromCodes, normalizeWordAttributeFilter } from '../utils/wordAttributeFilterCodes';
|
|
5
|
+
export type { WordAttributeFilterCodes, WordAttributeSectionCode } from '../utils/wordAttributeFilterCodes';
|
|
4
6
|
/**
|
|
5
7
|
* 등급/음절수/품사/어종 필터의 공용 i18n 키+맵.
|
|
6
8
|
* 단어표(useWordSearch)·대립쌍표(useWordPairsSearch)·단어훈련(useWordTrainingFilter)이
|
|
@@ -68,5 +70,10 @@ export declare function buildPositionSection(t: TFunction, opts: {
|
|
|
68
70
|
* @public 긍정형 다중 토글 섹션의 filterSelected 갱신 헬퍼 — 단어표·대립쌍표 공용.
|
|
69
71
|
* '전체'(FILTER_ALL_VALUE)면 해당 섹션 해제, 그 외엔 값 토글, 남은 값이 없으면 key 자체를 제거한다.
|
|
70
72
|
* (FilterPanel은 로케일과 무관하게 FILTER_ALL_VALUE를 방출하므로 t('common.all', '전체')과 비교하면 안 된다.)
|
|
73
|
+
*
|
|
74
|
+
* **속성 섹션(등급·음절 수·품사·어종)은 값을 코드로 견준다** — prev 에 다른 언어·옛 라벨로 든 같은 섹션이 있으면 그것을 읽어
|
|
75
|
+
* `sectionLabel` 의 언어 라벨로 합쳐 적는다. 전에는 한국어로 저장된 '품사' 가 영어 화면의 'Part of Speech' 옆에 따로 남아,
|
|
76
|
+
* 칩은 꺼진 채로 보이는데 필터는 걸려 있었고 끌 길이 없었다. 반환 모양(라벨 열쇠·라벨 값)은 그대로다.
|
|
77
|
+
* 저장할 때는 `toWordAttributeFilterCodes` 로 코드 모양을 함께 담는다.
|
|
71
78
|
*/
|
|
72
79
|
export declare function toggleAttributeFilter(prev: Record<string, string[]>, value: string, sectionLabel: string): Record<string, string[]>;
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
5
|
+
import { toWordAttributeFilterCodes, wordAttributeFilterFromCodes, wordAttributeSectionOf, wordAttributeValueCodeOf, wordAttributeLabelLanguageOf, } from '../utils/wordAttributeFilterCodes';
|
|
6
|
+
/* 저장용 코드 모양과 오가는 도구 — 공용 속성 필터를 언어와 무관하게 저장·복원한다(utils/wordAttributeFilterCodes) */
|
|
7
|
+
export { toWordAttributeFilterCodes, wordAttributeFilterFromCodes, normalizeWordAttributeFilter } from '../utils/wordAttributeFilterCodes';
|
|
5
8
|
/**
|
|
6
9
|
* @public
|
|
7
10
|
* 등급/음절수/품사/어종 필터의 공용 라벨+매핑을 만든다. 세 화면(단어표/대립쌍표/단어훈련)이
|
|
@@ -100,8 +103,30 @@ export function buildPositionSection(t, opts) {
|
|
|
100
103
|
* @public 긍정형 다중 토글 섹션의 filterSelected 갱신 헬퍼 — 단어표·대립쌍표 공용.
|
|
101
104
|
* '전체'(FILTER_ALL_VALUE)면 해당 섹션 해제, 그 외엔 값 토글, 남은 값이 없으면 key 자체를 제거한다.
|
|
102
105
|
* (FilterPanel은 로케일과 무관하게 FILTER_ALL_VALUE를 방출하므로 t('common.all', '전체')과 비교하면 안 된다.)
|
|
106
|
+
*
|
|
107
|
+
* **속성 섹션(등급·음절 수·품사·어종)은 값을 코드로 견준다** — prev 에 다른 언어·옛 라벨로 든 같은 섹션이 있으면 그것을 읽어
|
|
108
|
+
* `sectionLabel` 의 언어 라벨로 합쳐 적는다. 전에는 한국어로 저장된 '품사' 가 영어 화면의 'Part of Speech' 옆에 따로 남아,
|
|
109
|
+
* 칩은 꺼진 채로 보이는데 필터는 걸려 있었고 끌 길이 없었다. 반환 모양(라벨 열쇠·라벨 값)은 그대로다.
|
|
110
|
+
* 저장할 때는 `toWordAttributeFilterCodes` 로 코드 모양을 함께 담는다.
|
|
103
111
|
*/
|
|
104
112
|
export function toggleAttributeFilter(prev, value, sectionLabel) {
|
|
113
|
+
const 섹션 = wordAttributeSectionOf(sectionLabel);
|
|
114
|
+
const 값코드 = 섹션 && value !== FILTER_ALL_VALUE ? wordAttributeValueCodeOf(섹션, value) : null;
|
|
115
|
+
if (섹션 && (value === FILTER_ALL_VALUE || 값코드)) {
|
|
116
|
+
const 남김 = {};
|
|
117
|
+
const 같은섹션 = {};
|
|
118
|
+
for (const [k, v] of Object.entries(prev))
|
|
119
|
+
(wordAttributeSectionOf(k) === 섹션 ? 같은섹션 : 남김)[k] = v;
|
|
120
|
+
if (value === FILTER_ALL_VALUE)
|
|
121
|
+
return 남김;
|
|
122
|
+
const 지금 = toWordAttributeFilterCodes(같은섹션)[섹션] ?? [];
|
|
123
|
+
const 다음 = 지금.includes(값코드) ? 지금.filter(c => c !== 값코드) : [...지금, 값코드];
|
|
124
|
+
if (다음.length === 0)
|
|
125
|
+
return 남김;
|
|
126
|
+
const 라벨 = wordAttributeFilterFromCodes({ [섹션]: 다음 }, wordAttributeLabelLanguageOf(sectionLabel));
|
|
127
|
+
const 값들 = Object.values(라벨)[0] ?? [];
|
|
128
|
+
return { ...남김, [sectionLabel]: 값들 };
|
|
129
|
+
}
|
|
105
130
|
if (value === FILTER_ALL_VALUE) {
|
|
106
131
|
const { [sectionLabel]: _omit, ...rest } = prev;
|
|
107
132
|
return rest;
|
|
@@ -31,8 +31,16 @@ export const LOCALE_STORAGE_KEY = 'ihab_display_language';
|
|
|
31
31
|
* `test/versionOnEverySubpath.test.ts` 가 갈래마다 이것이 있는지 센다.
|
|
32
32
|
*/
|
|
33
33
|
export { CORE_VERSION } from '../version';
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* localStorage에서 저장된 언어 코드를 읽어온다. 없거나 지원하지 않는 코드면 기본값 반환.
|
|
36
|
+
*
|
|
37
|
+
* **서버(window 없음)에서는 읽지 않고 DEFAULT_LOCALE.** Node 22+ 는 자기 전역 localStorage 를 갖고 있어
|
|
38
|
+
* (`--localstorage-file`) 서버가 저장소에 남은 언어로 첫 화면을 그리면 브라우저의 첫 렌더와 어긋난다.
|
|
39
|
+
* 브라우저에서는 지금처럼 저장 언어로 시작한다(SPA 첫 화면 유지). SSR 앱은 수화 뒤 저장된 언어로 `i18n.changeLanguage` 한다.
|
|
40
|
+
*/
|
|
35
41
|
function getSavedLocale() {
|
|
42
|
+
if (typeof window === 'undefined')
|
|
43
|
+
return DEFAULT_LOCALE;
|
|
36
44
|
try {
|
|
37
45
|
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
38
46
|
if (saved && SUPPORTED_LOCALES.some(l => l.code === saved))
|
|
@@ -393,9 +393,11 @@
|
|
|
393
393
|
"cardInstructions": "Look at the word card, listen to the sound, and check it.",
|
|
394
394
|
"noCards": "No cards.",
|
|
395
395
|
"audioFetchError": "Failed to load the sound source.",
|
|
396
|
+
"ttsUnavailable": "Speech playback isn't available on this device.",
|
|
396
397
|
"selectConfirm": "Look at the question and select the answer.",
|
|
397
398
|
"orderListDefault": "Arrange the sentences in the order you heard.",
|
|
398
399
|
"result": {
|
|
400
|
+
"pendingDone": "Results are still being calculated, so you can't finish yet.",
|
|
399
401
|
"eyebrow": "Training Complete",
|
|
400
402
|
"title": "You've finished all the training!",
|
|
401
403
|
"scoreAria": "{{correct}} / {{total}} questions",
|
|
@@ -763,7 +765,7 @@
|
|
|
763
765
|
"empty": "No saved word tables.",
|
|
764
766
|
"loadButton": "Load",
|
|
765
767
|
"itemCount": "{{count}} words",
|
|
766
|
-
"deleteAriaLabel": "Delete",
|
|
768
|
+
"deleteAriaLabel": "Delete {{name}}",
|
|
767
769
|
"sort": {
|
|
768
770
|
"newest": "Newest",
|
|
769
771
|
"oldest": "Oldest",
|
|
@@ -885,7 +887,8 @@
|
|
|
885
887
|
"syllable": "Syllables ↑",
|
|
886
888
|
"syllableDesc": "Syllables ↓",
|
|
887
889
|
"grade": "Grade ↑",
|
|
888
|
-
"gradeDesc": "Grade ↓"
|
|
890
|
+
"gradeDesc": "Grade ↓",
|
|
891
|
+
"original": "Original order"
|
|
889
892
|
},
|
|
890
893
|
"pageSize": "Show {{count}}",
|
|
891
894
|
"pageSizeAria": "Page size",
|
|
@@ -1440,5 +1443,24 @@
|
|
|
1440
1443
|
},
|
|
1441
1444
|
"breadcrumb": {
|
|
1442
1445
|
"ariaLabel": "Breadcrumb"
|
|
1446
|
+
},
|
|
1447
|
+
"board": {
|
|
1448
|
+
"empty": "No items yet."
|
|
1449
|
+
},
|
|
1450
|
+
"promoBannerBg": {
|
|
1451
|
+
"main": "Main gradient",
|
|
1452
|
+
"soft": "Soft",
|
|
1453
|
+
"sub": "Sub color",
|
|
1454
|
+
"deep": "Deep gradient",
|
|
1455
|
+
"softSub": "Soft sub",
|
|
1456
|
+
"warm": "Warm (accent)",
|
|
1457
|
+
"dark": "Dark",
|
|
1458
|
+
"radial": "Radial gradient",
|
|
1459
|
+
"diagonal": "Diagonal pattern",
|
|
1460
|
+
"dots": "Dot pattern",
|
|
1461
|
+
"outline": "Outline",
|
|
1462
|
+
"success": "Success / done",
|
|
1463
|
+
"naver": "Naver blog notice",
|
|
1464
|
+
"instagram": "Instagram notice"
|
|
1443
1465
|
}
|
|
1444
1466
|
}
|
|
@@ -391,9 +391,11 @@
|
|
|
391
391
|
"cardInstructions": "낱말 카드를 보고 소리를 듣고 확인하세요.",
|
|
392
392
|
"noCards": "카드가 없습니다.",
|
|
393
393
|
"audioFetchError": "사운드 소스를 가져오지 못했습니다.",
|
|
394
|
+
"ttsUnavailable": "이 기기에서는 음성을 들려줄 수 없습니다.",
|
|
394
395
|
"selectConfirm": "문제를 보고 해당하는 내용을 선택하세요.",
|
|
395
396
|
"orderListDefault": "문장을 들은 순서대로 배치하세요.",
|
|
396
397
|
"result": {
|
|
398
|
+
"pendingDone": "결과를 계산하는 중이라 아직 완료할 수 없습니다.",
|
|
397
399
|
"eyebrow": "훈련 완료",
|
|
398
400
|
"title": "훈련을 모두 마쳤습니다.",
|
|
399
401
|
"scoreAria": "{{correct}} / {{total}} 문항",
|
|
@@ -761,7 +763,7 @@
|
|
|
761
763
|
"empty": "저장된 단어 표가 없습니다.",
|
|
762
764
|
"loadButton": "불러오기",
|
|
763
765
|
"itemCount": "{{count}}개",
|
|
764
|
-
"deleteAriaLabel": "삭제",
|
|
766
|
+
"deleteAriaLabel": "{{name}} 삭제",
|
|
765
767
|
"sort": {
|
|
766
768
|
"newest": "최신순",
|
|
767
769
|
"oldest": "오래된순",
|
|
@@ -883,7 +885,8 @@
|
|
|
883
885
|
"syllable": "음절 수 ↑",
|
|
884
886
|
"syllableDesc": "음절 수 ↓",
|
|
885
887
|
"grade": "등급순 ↑",
|
|
886
|
-
"gradeDesc": "등급순 ↓"
|
|
888
|
+
"gradeDesc": "등급순 ↓",
|
|
889
|
+
"original": "받은 순서"
|
|
887
890
|
},
|
|
888
891
|
"pageSize": "{{count}}개씩 보기",
|
|
889
892
|
"pageSizeAria": "페이지 크기",
|
|
@@ -1438,5 +1441,24 @@
|
|
|
1438
1441
|
},
|
|
1439
1442
|
"breadcrumb": {
|
|
1440
1443
|
"ariaLabel": "이동 경로"
|
|
1444
|
+
},
|
|
1445
|
+
"board": {
|
|
1446
|
+
"empty": "등록된 항목이 없습니다."
|
|
1447
|
+
},
|
|
1448
|
+
"promoBannerBg": {
|
|
1449
|
+
"main": "메인 그라디언트",
|
|
1450
|
+
"soft": "소프트",
|
|
1451
|
+
"sub": "서브 컬러",
|
|
1452
|
+
"deep": "딥 그라디언트",
|
|
1453
|
+
"softSub": "소프트 서브",
|
|
1454
|
+
"warm": "웜(강조)",
|
|
1455
|
+
"dark": "다크",
|
|
1456
|
+
"radial": "방사형 그라디언트",
|
|
1457
|
+
"diagonal": "대각선 패턴",
|
|
1458
|
+
"dots": "도트 패턴",
|
|
1459
|
+
"outline": "아웃라인",
|
|
1460
|
+
"success": "성공/완료",
|
|
1461
|
+
"naver": "네이버 블로그 안내",
|
|
1462
|
+
"instagram": "인스타그램 안내"
|
|
1441
1463
|
}
|
|
1442
1464
|
}
|
|
@@ -244,8 +244,13 @@
|
|
|
244
244
|
--z-content: 1;
|
|
245
245
|
--z-dropdown: 200;
|
|
246
246
|
--z-sticky: 500;
|
|
247
|
+
/* 선택 막대(BulkBar) — 페이지 내용·붙박이 위, 내비·메뉴 아래 */
|
|
248
|
+
--z-bulk-bar: 600;
|
|
247
249
|
--z-nav: 1000;
|
|
248
250
|
--z-sidebar: 1100;
|
|
251
|
+
/* body 로 빠지는 메뉴(Dropdown·DatePicker·Header·BottomNav 더보기) — 내비·사이드바 위, 창(overlay·page-popup·modal) 아래.
|
|
252
|
+
창·툴팁 안에서 연 메뉴는 anchoredMenuZIndex 가 그 층 바로 위로 올린다. 서열: 페이지 < BulkBar < 메뉴 < 창 < 툴팁 < 알림 < 토스트 */
|
|
253
|
+
--z-menu: 1500;
|
|
249
254
|
--z-overlay: 2000;
|
|
250
255
|
--z-page-popup: 2100; /* 훈련 팝업 전체화면 */
|
|
251
256
|
/* 플로팅 버튼(버그신고 FAB 등) — page-popup 위에 떠서 훈련 중에도 쓸 수 있어야 하지만,
|
|
@@ -34,12 +34,24 @@ export interface WordTableSearchConfig {
|
|
|
34
34
|
inputMode: 'word' | 'phoneme';
|
|
35
35
|
positionCount: number;
|
|
36
36
|
filterScope: 'all' | 'searchWordOnly';
|
|
37
|
+
/** 필터 선택(라벨 열쇠·라벨 값 — 저장한 때의 언어). 속성 섹션은 어느 언어로 적혀 있어도 코어가 읽는다. */
|
|
37
38
|
filterSelected: Record<string, string[]>;
|
|
39
|
+
/**
|
|
40
|
+
* 선택. 공용 속성 필터(등급·음절 수·품사·어종)를 **언어와 무관한 코드**로 — `toWordAttributeFilterCodes(filterSelected)`.
|
|
41
|
+
* 있으면 복원할 때 `wordAttributeFilterFromCodes` 로 지금 언어 라벨을 만든다(라벨 쪽보다 앞선다).
|
|
42
|
+
*/
|
|
43
|
+
attributeCodes?: import('../utils/wordAttributeFilterCodes').WordAttributeFilterCodes;
|
|
38
44
|
phonemeSelected: Record<string, string>[];
|
|
39
45
|
}
|
|
40
46
|
/** 단어표(단일)의 검색 구성 — 수정 시 필터 패널을 복원하기 위해 저장한다. */
|
|
41
47
|
export interface WordSearchFilterConfig {
|
|
48
|
+
/** 필터 선택(라벨 열쇠·라벨 값 — 저장한 때의 언어). 속성 섹션은 어느 언어로 적혀 있어도 코어가 읽는다. */
|
|
42
49
|
filterSelected: Record<string, string | string[]>;
|
|
50
|
+
/**
|
|
51
|
+
* 선택. 공용 속성 필터(등급·음절 수·품사·어종)를 **언어와 무관한 코드**로 — `toWordAttributeFilterCodes(filterSelected)`.
|
|
52
|
+
* 있으면 복원할 때 `wordAttributeFilterFromCodes` 로 지금 언어 라벨을 만든다(라벨 쪽보다 앞선다).
|
|
53
|
+
*/
|
|
54
|
+
attributeCodes?: import('../utils/wordAttributeFilterCodes').WordAttributeFilterCodes;
|
|
43
55
|
/**
|
|
44
56
|
* 입력 방식 — 'char'(글자 선택, 기본) | 'pron'(발음 선택) | 'direct'(글자 입력·직접입력).
|
|
45
57
|
* 'char'/'pron'은 초성/중성/종성+위치 버튼 선택 모드로 UI는 동일하고 매칭 대상만 철자(word)/표준발음(pronunciation)으로 다르다.
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* 뒤 열이 통째로 밀린다.
|
|
10
10
|
* 2. **수식으로 실행되지 않게.** `=`·`+`·`-`·`@` 로 시작하는 값을 엑셀·시트가 수식으로
|
|
11
11
|
* 실행한다(CSV Injection). 탭이나 CR 로 시작하는 경우도 파서가 앞의 공백문자를 버리고
|
|
12
|
-
* 그다음 글자를 첫 글자로 읽으므로 같은 결과가
|
|
12
|
+
* 그다음 글자를 첫 글자로 읽으므로 같은 결과가 된다. 앞에 **작은따옴표 `'`** 를 세운다(OWASP 권고) —
|
|
13
|
+
* 전에는 탭을 세웠는데, 탭도 앞 공백으로 버리는 판에서는 수식이 그대로 살아났다.
|
|
14
|
+
*
|
|
15
|
+
* 선행 0(`007`·`010-…`)은 건드리지 않는다(결정 2026-09-15) — 엑셀이 숫자로 읽어 0 을 떼는 것은 여는 쪽의 일이고,
|
|
16
|
+
* 여기서 글자로 박으면 그 값을 다시 읽는 프로그램이 따옴표를 떠안는다.
|
|
13
17
|
*
|
|
14
18
|
* 이 규칙이 두 군데에 따로 적혀 있었고, 고객 목록 쪽 사본이 `\r` 둘 다를 빠뜨린 채
|
|
15
19
|
* "어드민과 동일한 규칙"이라고 주석까지 달고 있었다.
|
|
@@ -17,6 +21,7 @@
|
|
|
17
21
|
export declare function escapeCsvField(v: unknown): string;
|
|
18
22
|
/**
|
|
19
23
|
* 브라우저에서 CSV 파일을 내려받게 한다. 앞에 BOM 을 붙인다 — 없으면 엑셀이 한글을
|
|
20
|
-
* 깨뜨린다.
|
|
24
|
+
* 깨뜨린다. **이미 BOM 으로 시작하는 글에는 또 붙이지 않는다**(두 번 붙으면 엑셀 첫 칸 머리에 보이지 않는 글자가 낀다).
|
|
25
|
+
* Blob 주소는 누르고 조금 뒤에 회수한다.
|
|
21
26
|
*/
|
|
22
27
|
export declare function downloadCsvText(filename: string, csv: string): void;
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* 뒤 열이 통째로 밀린다.
|
|
10
10
|
* 2. **수식으로 실행되지 않게.** `=`·`+`·`-`·`@` 로 시작하는 값을 엑셀·시트가 수식으로
|
|
11
11
|
* 실행한다(CSV Injection). 탭이나 CR 로 시작하는 경우도 파서가 앞의 공백문자를 버리고
|
|
12
|
-
* 그다음 글자를 첫 글자로 읽으므로 같은 결과가
|
|
12
|
+
* 그다음 글자를 첫 글자로 읽으므로 같은 결과가 된다. 앞에 **작은따옴표 `'`** 를 세운다(OWASP 권고) —
|
|
13
|
+
* 전에는 탭을 세웠는데, 탭도 앞 공백으로 버리는 판에서는 수식이 그대로 살아났다.
|
|
14
|
+
*
|
|
15
|
+
* 선행 0(`007`·`010-…`)은 건드리지 않는다(결정 2026-09-15) — 엑셀이 숫자로 읽어 0 을 떼는 것은 여는 쪽의 일이고,
|
|
16
|
+
* 여기서 글자로 박으면 그 값을 다시 읽는 프로그램이 따옴표를 떠안는다.
|
|
13
17
|
*
|
|
14
18
|
* 이 규칙이 두 군데에 따로 적혀 있었고, 고객 목록 쪽 사본이 `\r` 둘 다를 빠뜨린 채
|
|
15
19
|
* "어드민과 동일한 규칙"이라고 주석까지 달고 있었다.
|
|
@@ -19,19 +23,22 @@ export function escapeCsvField(v) {
|
|
|
19
23
|
/* 숫자 값은 수식이 아니다 — 음수(환불 금액 등)에 탭을 붙이면 엑셀이 글자 칸으로 읽어 합계가 안 된다.
|
|
20
24
|
글자 '-2+3' 은 그대로 막는다. */
|
|
21
25
|
if (!(typeof v === 'number' && Number.isFinite(v)) && /^[=+\-@\t\r]/.test(s))
|
|
22
|
-
s = '
|
|
26
|
+
s = "'" + s;
|
|
23
27
|
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
|
24
28
|
}
|
|
29
|
+
/** 내려받기를 누른 뒤 Blob 주소를 회수하기까지 — 곧바로 회수하면 Safari 가 내려받기를 시작하기 전에 주소가 사라졌다. */
|
|
30
|
+
const BLOB_REVOKE_DELAY_MS = 1000;
|
|
25
31
|
/**
|
|
26
32
|
* 브라우저에서 CSV 파일을 내려받게 한다. 앞에 BOM 을 붙인다 — 없으면 엑셀이 한글을
|
|
27
|
-
* 깨뜨린다.
|
|
33
|
+
* 깨뜨린다. **이미 BOM 으로 시작하는 글에는 또 붙이지 않는다**(두 번 붙으면 엑셀 첫 칸 머리에 보이지 않는 글자가 낀다).
|
|
34
|
+
* Blob 주소는 누르고 조금 뒤에 회수한다.
|
|
28
35
|
*/
|
|
29
36
|
export function downloadCsvText(filename, csv) {
|
|
30
|
-
const blob = new Blob(['
|
|
37
|
+
const blob = new Blob([csv.startsWith('\uFEFF') ? csv : '\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
|
31
38
|
const url = URL.createObjectURL(blob);
|
|
32
39
|
const a = document.createElement('a');
|
|
33
40
|
a.href = url;
|
|
34
41
|
a.download = filename;
|
|
35
42
|
a.click();
|
|
36
|
-
URL.revokeObjectURL(url);
|
|
43
|
+
setTimeout(() => URL.revokeObjectURL(url), BLOB_REVOKE_DELAY_MS);
|
|
37
44
|
}
|
|
@@ -25,14 +25,45 @@ function mulberry32(seed) {
|
|
|
25
25
|
}
|
|
26
26
|
// shuffle/sample은 공용 utils/array로 통일(시드 rng 지원 동일). 여기선 sampleArray 이름만 유지해 내부에서 사용.
|
|
27
27
|
const sampleArray = sample;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
28
|
+
/** KST 날짜 'YYYY-MM-DD' → 날 번호(1970-01-01 = 0). 홀짝으로 이틀마다 번갈아 쓸 반쪽을 고른다. */
|
|
29
|
+
const 날번호 = (ymd) => {
|
|
30
|
+
const [y, m, d] = ymd.split('-').map(Number);
|
|
31
|
+
return Math.round(Date.UTC(y, (m || 1) - 1, d || 1) / 86_400_000);
|
|
32
|
+
};
|
|
33
|
+
/** 항목을 가리키는 열쇠 — 자료의 id(없으면 name). 자료가 늘거나 차례가 바뀌어도 그 항목의 반쪽이 안 바뀌게 */
|
|
34
|
+
const 항목열쇠 = (it, i) => {
|
|
35
|
+
const o = (it ?? {});
|
|
36
|
+
return String(o.id ?? o.name ?? i);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* 한 유형의 그 날 세트.
|
|
40
|
+
*
|
|
41
|
+
* **(c) 시드는 유형마다 따로**(사용자|날짜|유형) — 전에는 네 유형이 난수 한 줄기를 차례로 나눠 써서, 짧은 이야기 자료가 하나
|
|
42
|
+
* 늘면 뒤 유형(긴 이야기·순서 맞추기) 세트까지 바뀌었다.
|
|
43
|
+
*
|
|
44
|
+
* **(b) 어제 고른 항목은 오늘 빼고 고른다.** 저장 없이 결정적으로 뽑는 훅이라 "어제 세트" 는 어제 날짜로 같은 규칙을 다시 계산한
|
|
45
|
+
* 것인데, 그것을 빼고 뽑는 규칙을 그대로 되풀이하면 어제 세트도 그제 세트를 빼고 뽑은 것이어야 해서 끝없이 거슬러 올라가고,
|
|
46
|
+
* 한 단계만 거슬러 올라가면 '어제 보기' 가 어제 실제로 뜬 세트와 달라진다. 그래서 **항목마다 사용자·유형으로 반쪽(홀·짝)을
|
|
47
|
+
* 정하고, 날 번호의 홀짝에 맞는 반쪽에서만 뽑는다** — 이웃한 두 날은 늘 다른 반쪽이라 겹치지 않고, 어제 보기는 어제 계산과 같다.
|
|
48
|
+
* 두 반쪽이 모두 필요한 개수 이상일 때만(후보가 충분할 때) 이렇게 하고, 모자라면 전처럼 전체에서 뽑는다(빈 세트로 두지 않는다).
|
|
49
|
+
*/
|
|
50
|
+
function 유형세트(pool, n, userId, ymd, type) {
|
|
51
|
+
if (!pool.length || n <= 0)
|
|
52
|
+
return [];
|
|
53
|
+
const rng = mulberry32(hashSeed(`${userId}|${ymd}|${type}`));
|
|
54
|
+
const 쪽 = 날번호(ymd) & 1;
|
|
55
|
+
const 이쪽 = pool.filter((it, i) => ((hashSeed(`${userId}|${type}|${항목열쇠(it, i)}`) >>> 7) & 1) === 쪽);
|
|
56
|
+
const 충분 = 이쪽.length >= n && pool.length - 이쪽.length >= n;
|
|
57
|
+
return sampleArray((충분 ? 이쪽 : pool), n, rng);
|
|
58
|
+
}
|
|
59
|
+
// (user, 날짜)로 결정적 추출 → 기기/세션 무관 동일 선택. 유형마다 시드가 따로다(위 유형세트).
|
|
60
|
+
function createSelection(counts, userId, ymd) {
|
|
61
|
+
const 개수 = (k) => Math.max(0, counts[k] || 0);
|
|
31
62
|
return {
|
|
32
|
-
LISTEN_IN_NOISE:
|
|
33
|
-
SHORT_STORY:
|
|
34
|
-
LONG_STORY:
|
|
35
|
-
ORDERING_SENTENCE:
|
|
63
|
+
LISTEN_IN_NOISE: 유형세트(NOISE_DATAS, 개수('LISTEN_IN_NOISE'), userId, ymd, 'LISTEN_IN_NOISE'),
|
|
64
|
+
SHORT_STORY: 유형세트(SHORT_STORIES, 개수('SHORT_STORY'), userId, ymd, 'SHORT_STORY'),
|
|
65
|
+
LONG_STORY: 유형세트(LONG_STORIES, 개수('LONG_STORY'), userId, ymd, 'LONG_STORY'),
|
|
66
|
+
ORDERING_SENTENCE: 유형세트(ORDER_SETS, 개수('ORDERING_SENTENCE'), userId, ymd, 'ORDERING_SENTENCE'),
|
|
36
67
|
};
|
|
37
68
|
}
|
|
38
69
|
export function hasItems(s) {
|
|
@@ -48,21 +79,20 @@ const DEFAULT_TRAINING_COUNTS = {
|
|
|
48
79
|
LONG_STORY: 1,
|
|
49
80
|
ORDERING_SENTENCE: 2,
|
|
50
81
|
};
|
|
51
|
-
// userId 시드 → (user,
|
|
52
|
-
// localStorage 캐시 없이 매번 결정적으로 재계산한다(저장 없음).
|
|
82
|
+
// userId 시드 → (user, 날짜, 유형)별 결정적 선택. 모든 기기/세션에서 동일하므로
|
|
83
|
+
// localStorage 캐시 없이 매번 결정적으로 재계산한다(저장 없음). 이웃한 두 날의 세트는 후보가 충분하면 겹치지 않는다(유형세트).
|
|
53
84
|
export function resolveSelection(_forceRefresh = false, counts = DEFAULT_TRAINING_COUNTS, userId) {
|
|
54
85
|
// 서버 완료집계와 동일하게 KST 기준 날짜. userId 없어도 날짜로 결정적(Math.random 비결정성 제거)
|
|
55
86
|
const today = toKstYmd(Date.now());
|
|
56
87
|
const yesterday = toKstYmd(Date.now() - 86400000);
|
|
57
|
-
const
|
|
58
|
-
const seedYesterday = `${userId ?? ''}|${yesterday}`;
|
|
88
|
+
const u = userId ?? '';
|
|
59
89
|
const hasSource = NOISE_DATAS.length > 0 || SHORT_STORIES.length > 0 ||
|
|
60
90
|
LONG_STORIES.length > 0 || ORDER_SETS.length > 0;
|
|
61
91
|
if (!hasSource) {
|
|
62
|
-
return { today: createSelection(counts,
|
|
92
|
+
return { today: createSelection(counts, u, today), yesterday: null };
|
|
63
93
|
}
|
|
64
94
|
return {
|
|
65
|
-
today: createSelection(counts,
|
|
66
|
-
yesterday: createSelection(counts,
|
|
95
|
+
today: createSelection(counts, u, today),
|
|
96
|
+
yesterday: createSelection(counts, u, yesterday),
|
|
67
97
|
};
|
|
68
98
|
}
|
|
@@ -28,6 +28,12 @@ export declare function formatDatetimeKo(iso: string): string;
|
|
|
28
28
|
export declare function toKstHm(when?: string | number | Date | null): string;
|
|
29
29
|
/** KST(Asia/Seoul) 기준 오늘 날짜를 'YYYY-MM-DD'로 반환. DB 날짜 비교 기준선으로 사용. */
|
|
30
30
|
export declare function getTodayKst(): string;
|
|
31
|
+
/**
|
|
32
|
+
* 부르는 쪽이 정한 '오늘'('YYYY-MM-DD')이 있으면 그것, 없거나 꼴이 틀리면 KST 오늘.
|
|
33
|
+
* Calendar·DatePicker·useCalendarMonth 의 `today` 가 쓴다(로컬 앱·SSR 이 서버와 같은 날을 넘기는 자리).
|
|
34
|
+
* 공개 export 아님(utils.ts 가 다시 내보내지 않는다).
|
|
35
|
+
*/
|
|
36
|
+
export declare function todayYmdOr(today?: string | null): string;
|
|
31
37
|
/**
|
|
32
38
|
* KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
|
|
33
39
|
* '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
|
|
@@ -63,11 +69,13 @@ export declare function parseLocalYmd(ymd: string): Date;
|
|
|
63
69
|
* 문자열을 낸다 — 연·월·일 경계 표본으로 대조했다.)
|
|
64
70
|
*
|
|
65
71
|
* 빈 값·못 읽는 값은 빈 문자열이다. 0(epoch)은 유효한 값이라 그대로 옮긴다.
|
|
72
|
+
* 시간대 없는 시각 글자('2026-09-30 20:00:00')는 KST 벽시계 시각으로 읽는다(날짜만 있는 글자는 그대로).
|
|
66
73
|
*/
|
|
67
74
|
export declare function toKstYmd(when?: string | number | Date | null): string;
|
|
68
75
|
type TranslateFn = import('i18next').TFunction;
|
|
69
76
|
/**
|
|
70
77
|
* ISO 문자열을 'store.time.*' i18n 키 기반 상대시간 문자열로 변환.
|
|
78
|
+
* 시간대 없는 시각 글자는 KST 벽시계 시각으로 읽는다.
|
|
71
79
|
* (예: 방금 전 / N분 전 / N시간 전 / N일 전 …)
|
|
72
80
|
* 빈/유효하지 않은 입력은 빈 문자열을 반환한다.
|
|
73
81
|
*/
|
|
@@ -16,12 +16,35 @@
|
|
|
16
16
|
* 영어로 보더라도 가리키는 날은 같아야 한다.
|
|
17
17
|
*/
|
|
18
18
|
const KST = 'Asia/Seoul';
|
|
19
|
+
/* 시간대 없는 벽시계 시각 — 'YYYY-MM-DDTHH:mm[:ss[.fff…]]' 또는 가운데가 빈칸('YYYY-MM-DD HH:mm:ss', Postgres timestamp 꼴).
|
|
20
|
+
뒤돌아보기 없이(iOS 15 Safari) 끝까지 맞춘다 — 뒤에 Z·±hh:mm 이 붙으면 걸리지 않는다. */
|
|
21
|
+
const 벽시계시각 = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?$/;
|
|
22
|
+
/**
|
|
23
|
+
* 날짜·시각 값을 순간(Date)으로 읽는 **이 파일의 공용 파서.** 못 읽는 값(빈 값·깨진 글자)이면 null.
|
|
24
|
+
*
|
|
25
|
+
* **시간대 없는 시각 글자는 KST 벽시계 시각으로 읽는다**(끝에 +09:00 을 붙인 것처럼, 2026-09-15 결정).
|
|
26
|
+
* `new Date('2026-09-30T20:00:00')` 은 **기기 지역시각**으로 해석된다 — KST 기기에서는 우연히 맞고, 뉴욕 기기에서는
|
|
27
|
+
* 13시간 밀려 날짜까지 바뀌었다. 빈칸 꼴('2026-09-30 20:00:00')은 Safari 가 아예 못 읽었다.
|
|
28
|
+
* 날짜만 있는 'YYYY-MM-DD' 는 지금 규칙(UTC 자정 = KST 같은 날 9시)을 그대로 따른다.
|
|
29
|
+
* Date·숫자·오프셋 있는 글자는 그대로.
|
|
30
|
+
*/
|
|
31
|
+
function 순간으로(when) {
|
|
32
|
+
let d;
|
|
33
|
+
if (when instanceof Date)
|
|
34
|
+
d = when;
|
|
35
|
+
else if (typeof when === 'string') {
|
|
36
|
+
const s = when.trim();
|
|
37
|
+
const m = 벽시계시각.exec(s);
|
|
38
|
+
/* 소수 초는 세 자리까지만 — 여섯 자리(Postgres)는 엔진마다 읽기가 갈린다 */
|
|
39
|
+
d = new Date(m ? `${m[1]}T${m[2]}:${m[3] ?? '00'}${m[4] ? `.${m[4].slice(0, 3).padEnd(3, '0')}` : ''}+09:00` : s);
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
d = new Date(when);
|
|
43
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
44
|
+
}
|
|
19
45
|
/* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
|
|
20
46
|
(같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
|
|
21
|
-
const 읽은날 = (iso) =>
|
|
22
|
-
const d = new Date(iso);
|
|
23
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
24
|
-
};
|
|
47
|
+
const 읽은날 = (iso) => 순간으로(iso);
|
|
25
48
|
export function formatDateKo(iso) {
|
|
26
49
|
const d = 읽은날(iso);
|
|
27
50
|
if (!d)
|
|
@@ -87,8 +110,8 @@ export function formatDatetimeKo(iso) {
|
|
|
87
110
|
export function toKstHm(when) {
|
|
88
111
|
if (when === null || when === undefined || when === '')
|
|
89
112
|
return '';
|
|
90
|
-
const d =
|
|
91
|
-
if (
|
|
113
|
+
const d = 순간으로(when);
|
|
114
|
+
if (!d)
|
|
92
115
|
return '';
|
|
93
116
|
return new Intl.DateTimeFormat('en-GB', {
|
|
94
117
|
timeZone: KST, hour: '2-digit', minute: '2-digit', hour12: false,
|
|
@@ -98,6 +121,14 @@ export function toKstHm(when) {
|
|
|
98
121
|
export function getTodayKst() {
|
|
99
122
|
return toKstYmd(new Date());
|
|
100
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* 부르는 쪽이 정한 '오늘'('YYYY-MM-DD')이 있으면 그것, 없거나 꼴이 틀리면 KST 오늘.
|
|
126
|
+
* Calendar·DatePicker·useCalendarMonth 의 `today` 가 쓴다(로컬 앱·SSR 이 서버와 같은 날을 넘기는 자리).
|
|
127
|
+
* 공개 export 아님(utils.ts 가 다시 내보내지 않는다).
|
|
128
|
+
*/
|
|
129
|
+
export function todayYmdOr(today) {
|
|
130
|
+
return today && /^\d{4}-\d{2}-\d{2}$/.test(today) ? today : getTodayKst();
|
|
131
|
+
}
|
|
101
132
|
/**
|
|
102
133
|
* KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
|
|
103
134
|
* '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
|
|
@@ -141,26 +172,29 @@ export function parseLocalYmd(ymd) {
|
|
|
141
172
|
* 문자열을 낸다 — 연·월·일 경계 표본으로 대조했다.)
|
|
142
173
|
*
|
|
143
174
|
* 빈 값·못 읽는 값은 빈 문자열이다. 0(epoch)은 유효한 값이라 그대로 옮긴다.
|
|
175
|
+
* 시간대 없는 시각 글자('2026-09-30 20:00:00')는 KST 벽시계 시각으로 읽는다(날짜만 있는 글자는 그대로).
|
|
144
176
|
*/
|
|
145
177
|
export function toKstYmd(when) {
|
|
146
178
|
if (when === null || when === undefined || when === '')
|
|
147
179
|
return '';
|
|
148
|
-
const d =
|
|
149
|
-
if (
|
|
180
|
+
const d = 순간으로(when);
|
|
181
|
+
if (!d)
|
|
150
182
|
return '';
|
|
151
183
|
return new Intl.DateTimeFormat('fr-CA', { timeZone: 'Asia/Seoul' }).format(d);
|
|
152
184
|
}
|
|
153
185
|
/**
|
|
154
186
|
* ISO 문자열을 'store.time.*' i18n 키 기반 상대시간 문자열로 변환.
|
|
187
|
+
* 시간대 없는 시각 글자는 KST 벽시계 시각으로 읽는다.
|
|
155
188
|
* (예: 방금 전 / N분 전 / N시간 전 / N일 전 …)
|
|
156
189
|
* 빈/유효하지 않은 입력은 빈 문자열을 반환한다.
|
|
157
190
|
*/
|
|
158
191
|
export function formatRelativeTime(input, t) {
|
|
159
192
|
if (!input)
|
|
160
193
|
return '';
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
194
|
+
const d = 순간으로(input);
|
|
195
|
+
if (!d)
|
|
163
196
|
return '';
|
|
197
|
+
const then = d.getTime();
|
|
164
198
|
const sec = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
165
199
|
const min = Math.floor(sec / 60), hour = Math.floor(min / 60), day = Math.floor(hour / 24);
|
|
166
200
|
const week = Math.floor(day / 7), month = Math.floor(day / 30), year = Math.floor(day / 365);
|
|
@@ -11,7 +11,7 @@ export declare function formatPhone(raw: string): string;
|
|
|
11
11
|
* 그대로 잘랐는데(`2026-08-19T16:00:00Z` → `2026.08.19`), 그건 UTC 날짜라 KST 자정~
|
|
12
12
|
* 아침 아홉 시 사이의 값이 전부 하루 전으로 보였다. 이 함수를 쓰는 곳이 전부 그랬다 —
|
|
13
13
|
* 회원의 문의 등록일, 고객 등록일. 날짜만 있는 값(YYYY-MM-DD)은 시각이 없으니
|
|
14
|
-
* 옮길 것도 없어 그대로 둔다.
|
|
14
|
+
* 옮길 것도 없어 그대로 둔다. 시간대 없는 시각('2026-09-30 20:00:00')은 KST 벽시계 시각으로 읽는다.
|
|
15
15
|
*/
|
|
16
16
|
export declare function normalizeDate(input: string | null | undefined): string | undefined;
|
|
17
17
|
/**
|