@ihabdevteam/core 0.314.0 → 0.318.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 +242 -0
- package/dist/components.cjs.js +182 -182
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +188 -188
- package/dist/ihabdevteam-core.d.ts +50 -3
- package/dist/packages/core/src/components/BarChart.js +16 -1
- package/dist/packages/core/src/components/Footer.js +1 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +12 -2
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +1 -1
- package/dist/packages/core/src/components/Table.js +5 -3
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +4 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +5 -2
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +11 -6
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +13 -8
- package/dist/packages/core/src/components/Training/audioWaits.js +22 -4
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +32 -0
- package/dist/packages/core/src/components/Training/stepAnswerable.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +5 -1
- package/dist/packages/core/src/components/WordTable.js +3 -16
- package/dist/packages/core/src/components/wordTableLayout.d.ts +34 -0
- package/dist/packages/core/src/components/wordTableLayout.js +48 -0
- package/dist/packages/core/src/components.d.ts +1 -1
- package/dist/packages/core/src/components.js +1 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +6 -1
- package/dist/packages/core/src/i18n/locales/en.json +39 -18
- package/dist/packages/core/src/i18n/locales/ko.json +6 -1
- package/dist/packages/core/src/utils/wornDate.d.ts +14 -2
- package/dist/packages/core/src/utils/wornDate.js +38 -3
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1669,7 +1669,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1669
1669
|
*
|
|
1670
1670
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1671
1671
|
*/
|
|
1672
|
-
export declare const CORE_VERSION = "0.
|
|
1672
|
+
export declare const CORE_VERSION = "0.318.0";
|
|
1673
1673
|
|
|
1674
1674
|
/** @public */
|
|
1675
1675
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -5036,8 +5036,18 @@ export declare interface PopupListProps<T extends {
|
|
|
5036
5036
|
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
5037
5037
|
*/
|
|
5038
5038
|
keepOpenOnSelect?: boolean;
|
|
5039
|
-
/**
|
|
5040
|
-
|
|
5039
|
+
/**
|
|
5040
|
+
* 행 선택 콜백 — **안 주면 줄을 누를 수 없는 표가 된다**(말귀 요청 (120), 2026-10-12).
|
|
5041
|
+
*
|
|
5042
|
+
* 이 값이 그대로 `Table` 의 `onRowClick` 이 되고, `Table` 은 **그것이 있다는 것만 보고** 줄을
|
|
5043
|
+
* 꾸민다(손가락 커서 · `tabindex="0"` · *"줄을 누르면 자세히 봅니다"*). 그래서 **누를 것이 없는
|
|
5044
|
+
* 표도 빈 함수를 넘겨야** 했고, 그 꾸밈이 그대로 섰다 — 눈으로 보는 사람에게는 손가락 커서가,
|
|
5045
|
+
* 자판으로 쓰는 사람에게는 **줄마다 멈춤**이(스무 줄이면 스무 번), 낭독기에는 아무 일도 안 하는
|
|
5046
|
+
* 설명이 읽혔다(말귀 실측: 눌러도 아무 일도 안 난다).
|
|
5047
|
+
*
|
|
5048
|
+
* 안 주면 `Table` 의 `onRowClick` 도 비어 **그 꾸밈이 저절로 사라진다.**
|
|
5049
|
+
*/
|
|
5050
|
+
onSelect?: (item: T) => void;
|
|
5041
5051
|
/** 닫기 콜백 */
|
|
5042
5052
|
onClose: () => void;
|
|
5043
5053
|
/** 페이지당 항목 수 옵션. 미지정 시 기본 [10, 25, 50, 100] */
|
|
@@ -5247,6 +5257,14 @@ export declare interface PopupProps {
|
|
|
5247
5257
|
|
|
5248
5258
|
export declare const POS_LABEL: Record<string, string>;
|
|
5249
5259
|
|
|
5260
|
+
/**
|
|
5261
|
+
* **품사 약호 → 지금 언어의 이름**(`{ '명': 'Noun', … }`). `expandSlashLabel` 에 그대로 넘긴다.
|
|
5262
|
+
*
|
|
5263
|
+
* 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
|
|
5264
|
+
* @public
|
|
5265
|
+
*/
|
|
5266
|
+
export declare function posLabels(t: 번역): Record<string, string>;
|
|
5267
|
+
|
|
5250
5268
|
/**
|
|
5251
5269
|
* **늦게 받는 화면 청크를 한가할 때 미리 받아 둔다.**
|
|
5252
5270
|
*
|
|
@@ -10486,6 +10504,24 @@ export declare function withStepTimes(payload: unknown, shownAt?: string, answer
|
|
|
10486
10504
|
/** text 뒤에 와/과를 붙여 반환한다. */
|
|
10487
10505
|
export declare function withWaGwa(text: string): string;
|
|
10488
10506
|
|
|
10507
|
+
/**
|
|
10508
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
10509
|
+
*
|
|
10510
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
10511
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
10512
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
10513
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
10514
|
+
*
|
|
10515
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
10516
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
10517
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
10518
|
+
* @public
|
|
10519
|
+
*/
|
|
10520
|
+
export declare const WORD_LABEL_I18N_KEYS: {
|
|
10521
|
+
readonly pos: Record<string, string>;
|
|
10522
|
+
readonly wordType: Record<string, string>;
|
|
10523
|
+
};
|
|
10524
|
+
|
|
10489
10525
|
/**
|
|
10490
10526
|
* 한 계정이 통틀어 올릴 수 있는 새 제안 수. 서버가 강제하고, 화면은 남은 횟수 표시에 쓴다.
|
|
10491
10527
|
*
|
|
@@ -11492,6 +11528,12 @@ export declare interface WordTrainingType {
|
|
|
11492
11528
|
/** @public 카테고리 키 */
|
|
11493
11529
|
export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | 'HEAR_AND_PICK_SAME' | 'HEAR_AND_PICK_INCLUDED' | 'STARTS_WITH_SAME_SOUND' | 'LISTEN_AND_REMEMBER_ORDER' | 'MERGE_2_SYLLABLES' | 'MERGE_3_SYLLABLES' | 'MERGE_2_PHONEMES' | 'MERGE_3_PHONEMES' | 'DROP_1_FROM_2SYLL' | 'DROP_1_FROM_3SYLL' | 'DROP_PART_FROM_COMPOUND' | 'DROP_FINAL_CONSONANT_FROM_1SYLL' | 'REMOVE_CONSONANT_AND_IDENTIFY_PHONEME' | 'CHANGE_1_SYLL_IN_2SYLL' | 'CHANGE_1_SYLL_IN_3SYLL' | 'CHANGE_1_PHONEME_IN_1SYLL' | 'CHANGE_1_PHONEME_IN_2SYLL';
|
|
11494
11530
|
|
|
11531
|
+
/**
|
|
11532
|
+
* **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
|
|
11533
|
+
* @public
|
|
11534
|
+
*/
|
|
11535
|
+
export declare function wordTypeLabels(t: 번역): Record<string, string>;
|
|
11536
|
+
|
|
11495
11537
|
/**
|
|
11496
11538
|
* @public 틀린 문항만 — 음소별 오류를 세는 쪽이 먼저 볼 것이다.
|
|
11497
11539
|
*
|
|
@@ -11499,6 +11541,11 @@ export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | '
|
|
|
11499
11541
|
*/
|
|
11500
11542
|
export declare function wrongItems(items: readonly TrainingItemResult[]): TrainingItemResult[];
|
|
11501
11543
|
|
|
11544
|
+
/** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
|
|
11545
|
+
declare type 번역 = (k: string, o: {
|
|
11546
|
+
defaultValue: string;
|
|
11547
|
+
}) => string;
|
|
11548
|
+
|
|
11502
11549
|
declare type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
|
|
11503
11550
|
noiseRatio?: number;
|
|
11504
11551
|
};
|
|
@@ -430,9 +430,24 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
430
430
|
const isLarge = size === 'large';
|
|
431
431
|
const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
|
|
432
432
|
.filter(Boolean).join(' ') || undefined;
|
|
433
|
+
/**
|
|
434
|
+
* **상세보기 단추 이름에 그 카드 이름을 담는다**(말귀 요청 (119), 2026-10-12).
|
|
435
|
+
*
|
|
436
|
+
* 한 쪽에 이 차트가 여섯 장 서고 그중 셋이 이 단추를 단다. 이름이 늘 `상세보기` 한 가지라
|
|
437
|
+
* 낭독기로는 **"Details · Details · Details"** 뿐이어서 어느 카드의 것인지 가릴 길이 없었다
|
|
438
|
+
* (말귀 실측: 앱이 제 손으로 붙이는 고르개는 `Homework Progress view` 처럼 카드 이름을 담는데
|
|
439
|
+
* **코어가 세우는 셋만** 똑같았다).
|
|
440
|
+
*
|
|
441
|
+
* 이름은 바깥에서 더 받지 않는다 — **이미 `titleKey` 를 받고 있다.** 제목이 없을 때만 예전
|
|
442
|
+
* 글자로 떨어뜨려 쓰던 자리가 안 깨지게 한다.
|
|
443
|
+
*/
|
|
444
|
+
const 카드이름 = titleKey ? t(titleKey) : '';
|
|
445
|
+
const 상세이름 = 카드이름
|
|
446
|
+
? t('trainingStatus.detailFor', { name: 카드이름, defaultValue: '{{name}} 상세보기' })
|
|
447
|
+
: t('trainingStatus.detail', '상세보기');
|
|
433
448
|
return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, tooltip: cardTooltip, headSegment: headSegment,
|
|
434
449
|
/* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
|
|
435
|
-
headMenuItems: onDetail ? [{ label:
|
|
450
|
+
headMenuItems: onDetail ? [{ label: 상세이름, icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), titleLevel: titleLevel, titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : ({ collapsed }) => _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && !collapsed && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: 상세이름, showTooltipInfo: 상세이름, onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
|
|
436
451
|
// 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
|
|
437
452
|
_jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
|
|
438
453
|
// 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
|
|
@@ -15,5 +15,5 @@ export default function Footer({ onTermsClick, onPrivacyClick, contact } = {}) {
|
|
|
15
15
|
} }, children: t('footer.policy.terms', '이용약관') }) }), _jsx("li", { children: _jsx("a", { className: "footer-link", href: "#", onClick: e => { if (onPrivacyClick) {
|
|
16
16
|
e.preventDefault();
|
|
17
17
|
onPrivacyClick();
|
|
18
|
-
} }, children: t('footer.policy.privacy', '개인정보 처리방침') }) })] })] })] })] }), _jsx("hr", { className: "site-footer__rule" }), _jsxs("div", { className: "site-footer__bottom", children: [_jsx("div", { className: "site-footer__brand", children: _jsx("img", { src: logoSrc, alt: "IHAB", className: "site-footer__logo" }) }), _jsxs("div", { className: "site-footer__meta", children: [_jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.ceo', '대표') }), _jsx("span", { className: "footer-meta caption-1", children: t('footer.ceoName', '한지혜') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.address', '주소') }), _jsx("span", { className: "footer-meta caption-1", children: t('footer.addressValue', '강원도 춘천시 시청길 32 해찬빌딩 3층 302호') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.support', '고객센터') }), _jsxs("span", { className: "footer-meta caption-1", children: [email, " \u00A0 ", tel] })] })] })] }), _jsx("div", { className: "site-footer__copyright footer-copyright caption-1", children: "\u00A9 IHAB, Inc. All rights reserved." })] }) }));
|
|
18
|
+
} }, children: t('footer.policy.privacy', '개인정보 처리방침') }) })] })] })] })] }), _jsx("hr", { className: "site-footer__rule" }), _jsxs("div", { className: "site-footer__bottom", children: [_jsx("div", { className: "site-footer__brand", children: _jsx("img", { src: logoSrc, alt: "IHAB", className: "site-footer__logo" }) }), _jsxs("div", { className: "site-footer__meta", children: [_jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.ceo', '대표') }), ' ', _jsx("span", { className: "footer-meta caption-1", children: t('footer.ceoName', '한지혜') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.address', '주소') }), ' ', _jsx("span", { className: "footer-meta caption-1", children: t('footer.addressValue', '강원도 춘천시 시청길 32 해찬빌딩 3층 302호') })] }), _jsxs("div", { children: [_jsx("span", { className: "footer-title", children: t('footer.support', '고객센터') }), ' ', _jsxs("span", { className: "footer-meta caption-1", children: [email, " \u00A0 ", tel] })] })] })] }), _jsx("div", { className: "site-footer__copyright footer-copyright caption-1", children: "\u00A9 IHAB, Inc. All rights reserved." })] }) }));
|
|
19
19
|
}
|
|
@@ -5,10 +5,10 @@ import Button from './Button';
|
|
|
5
5
|
import { formatWornDate } from '../utils/wornDate';
|
|
6
6
|
import './HearingDeviceInfo.css';
|
|
7
7
|
export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, onRemoveDevice }) {
|
|
8
|
-
const { t } = useTranslation();
|
|
8
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
9
9
|
return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['좌', '우'].map(side => {
|
|
10
10
|
const d = devices[side];
|
|
11
11
|
const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
12
|
-
return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
|
|
12
|
+
return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
|
|
13
13
|
}) })] }));
|
|
14
14
|
}
|
|
@@ -151,7 +151,7 @@ function 메타(d) {
|
|
|
151
151
|
return [...new Set(조각)].join(' · ');
|
|
152
152
|
}
|
|
153
153
|
export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
154
|
-
const { t } = useTranslation();
|
|
154
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
155
155
|
const SIDES = ['좌', '우'];
|
|
156
156
|
return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
|
|
157
157
|
const d = devices[side];
|
|
@@ -162,6 +162,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
|
162
162
|
(잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
|
|
163
163
|
_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
|
|
164
164
|
// 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
|
|
165
|
-
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
165
|
+
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
166
166
|
}) }));
|
|
167
167
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useId } from 'react';
|
|
4
4
|
import { getErrorMessage } from '../../utils/errorMessage';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import Popup from './Popup';
|
|
@@ -12,6 +12,8 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
12
12
|
const [loading, setLoading] = useState(false);
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const inputRef = useRef(null);
|
|
15
|
+
/* 글귀와 칸을 잇는 id — 한 쪽에 이 창이 둘 설 일은 없지만 id 는 문서에서 하나여야 한다 */
|
|
16
|
+
const 글귀id = useId();
|
|
15
17
|
// Popup 자체 포커스 로직(패널 내 첫 focusable = 헤더 닫기 버튼)보다 먼저 실행되도록
|
|
16
18
|
// 자식 effect가 부모보다 먼저 도는 순서를 이용해 입력창을 선점 포커스한다.
|
|
17
19
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
@@ -44,5 +46,5 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
44
46
|
return (_jsx(Popup, { className: "coupon-input-popup", title: t('usage.couponButton', '쿠폰 입력'), size: "small", onClose: onCancel, closeDisabled: loading, buttons: [
|
|
45
47
|
{ label: t('common.cancel', '취소'), variant: 'default', disabled: loading, onClick: ({ closeWithAnimation }) => closeWithAnimation() },
|
|
46
48
|
{ label: t('coupons.applyButton', '적용'), variant: 'main', disabled: !code.trim() || loading, onClick: ({ closeWithAnimation }) => void handleApply(closeWithAnimation) },
|
|
47
|
-
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), placeholder: t('coupons.input.placeholder', '쿠폰 코드 입력'), value: code, onChange: handleChange, onKeyDown: e => handleKey(e, closeWithAnimation), disabled: loading, autoComplete: "off", spellCheck: false }), error && _jsx("p", { className: "coupon-input-popup__error caption-1", role: "alert", children: error })] })) }));
|
|
49
|
+
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), "aria-invalid": error ? true : undefined, "aria-errormessage": error ? 글귀id : undefined, "aria-describedby": error ? 글귀id : undefined, placeholder: t('coupons.input.placeholder', '쿠폰 코드 입력'), value: code, onChange: handleChange, onKeyDown: e => handleKey(e, closeWithAnimation), disabled: loading, autoComplete: "off", spellCheck: false }), error && _jsx("p", { id: 글귀id, className: "coupon-input-popup__error caption-1", role: "alert", children: error })] })) }));
|
|
48
50
|
}
|
|
@@ -30,7 +30,7 @@ function 정렬값(r, key) {
|
|
|
30
30
|
return wornDateSortKey(r.wornYear, r.wornMonth, r.wornDay);
|
|
31
31
|
}
|
|
32
32
|
export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
|
|
33
|
-
const { t } = useTranslation();
|
|
33
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
34
34
|
const 방향이름 = (side) => side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
35
35
|
/* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
36
36
|
쪽넘김보다 **앞에서** 정렬한다(훅에 이미 정렬된 줄을 건넨다). */
|
|
@@ -88,7 +88,7 @@ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onW
|
|
|
88
88
|
{ content: _jsx("span", { children: row.fitType || '-' }) },
|
|
89
89
|
{ content: _jsx("span", { children: row.productName || '-' }) },
|
|
90
90
|
{
|
|
91
|
-
content: _jsx("span", { className: "dlp-date", children: formatWornDate(row.wornYear, row.wornMonth, row.wornDay) || '-' }),
|
|
91
|
+
content: _jsx("span", { className: "dlp-date", children: formatWornDate(row.wornYear, row.wornMonth, row.wornDay, i18n.language) || '-' }),
|
|
92
92
|
},
|
|
93
93
|
/* 삭제를 넘겨준 화면에서만 이 칸이 선다 — 안 넘기면 칸 자체가 없어,
|
|
94
94
|
못 하는 일을 내밀지 않는다. 눌림은 줄로 새지 않게 막는다(줄을 누르면
|
|
@@ -52,8 +52,18 @@ export interface PopupListProps<T extends {
|
|
|
52
52
|
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
53
53
|
*/
|
|
54
54
|
keepOpenOnSelect?: boolean;
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 행 선택 콜백 — **안 주면 줄을 누를 수 없는 표가 된다**(말귀 요청 (120), 2026-10-12).
|
|
57
|
+
*
|
|
58
|
+
* 이 값이 그대로 `Table` 의 `onRowClick` 이 되고, `Table` 은 **그것이 있다는 것만 보고** 줄을
|
|
59
|
+
* 꾸민다(손가락 커서 · `tabindex="0"` · *"줄을 누르면 자세히 봅니다"*). 그래서 **누를 것이 없는
|
|
60
|
+
* 표도 빈 함수를 넘겨야** 했고, 그 꾸밈이 그대로 섰다 — 눈으로 보는 사람에게는 손가락 커서가,
|
|
61
|
+
* 자판으로 쓰는 사람에게는 **줄마다 멈춤**이(스무 줄이면 스무 번), 낭독기에는 아무 일도 안 하는
|
|
62
|
+
* 설명이 읽혔다(말귀 실측: 눌러도 아무 일도 안 난다).
|
|
63
|
+
*
|
|
64
|
+
* 안 주면 `Table` 의 `onRowClick` 도 비어 **그 꾸밈이 저절로 사라진다.**
|
|
65
|
+
*/
|
|
66
|
+
onSelect?: (item: T) => void;
|
|
57
67
|
/** 닫기 콜백 */
|
|
58
68
|
onClose: () => void;
|
|
59
69
|
/** 페이지당 항목 수 옵션. 미지정 시 기본 [10, 25, 50, 100] */
|
|
@@ -74,5 +74,5 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
|
|
|
74
74
|
? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
|
|
75
75
|
: isError
|
|
76
76
|
? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
|
|
77
|
-
: _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
|
|
77
|
+
: _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount), count: wordCount }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
|
|
78
78
|
}
|
|
@@ -229,9 +229,11 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
229
229
|
: null;
|
|
230
230
|
/* **안-모음: 모을 값이 없다**(2026-10-10 조사에서 한 번 모아 봤다가 되돌림). `넘침재기` 는
|
|
231
231
|
`scrollWidth > clientWidth` 하나를 **불리언**으로 넣는데, 같은 값이면 React 가 그 자리에서
|
|
232
|
-
멈춘다 — 몰려 와도
|
|
233
|
-
|
|
234
|
-
글자로
|
|
232
|
+
멈춘다 — resize 가 몰려 와도 **다시 그리지 않는다.** 읽는 것도 그 두 값뿐이다.
|
|
233
|
+
|
|
234
|
+
(처음엔 *"못이 이 배선을 글자로 박아 두었다"* 도 까닭으로 적었는데, 그 못을 **꼴을 재는 쪽으로
|
|
235
|
+
고쳤으니** 이제 그 까닭은 없다 — 지운다. 남는 까닭은 위의 하나뿐이고, 그것만으로 충분하다.
|
|
236
|
+
**까닭이 틀린 주석은 다음 사람에게 잘못된 값을 준다.**) */
|
|
235
237
|
window.addEventListener('resize', 넘침재기);
|
|
236
238
|
return () => {
|
|
237
239
|
if (다음틀 != null)
|
|
@@ -19,6 +19,10 @@ export function getNoiseLevelOptions(t) {
|
|
|
19
19
|
/** @public */
|
|
20
20
|
export function getNoiseTypeOptions(t) {
|
|
21
21
|
return [
|
|
22
|
+
/* **폭죽은 일부러 둔다 — 사장님 결정 2026-10-10.** (116) 에서 훈련 갈래의 `party-popper` 를 걷으며
|
|
23
|
+
*"여기도 같은 꼴 아닌가"* 로 올렸고(형제 셋은 `car`·`message-square-more`·`coffee` 로 **그 자리를
|
|
24
|
+
그린다**), 넷(`waves` · `volume-2` · `wind` · 그대로) 중 **그대로**를 고르셨다.
|
|
25
|
+
**같은 꼴이라고 같은 결정이 아니다** — 고치지 말 것. 바꾸려면 다시 물어야 한다. */
|
|
22
26
|
{ label: t('training.noise.type.default', '기본'), value: "defaultNoise", icon: "party-popper" },
|
|
23
27
|
{ label: t('training.noise.type.street', '길거리'), value: "street", icon: "car" },
|
|
24
28
|
{ label: t('training.noise.type.buzz', '와글와글'), value: "buzz", icon: "message-square-more" },
|
|
@@ -105,8 +105,11 @@ export default function TrainingBlockButton(props) {
|
|
|
105
105
|
catch (_) { }
|
|
106
106
|
// 아이콘 호버·팝업 툴팁 — 카테고리가 있으면 카테고리, 없으면 훈련 타입 이름, 그마저 없으면 제목.
|
|
107
107
|
const popupIconTooltip = subName ? `${mainName} / ${subName}` : (mainName || type?.label || itemTitle);
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
/* **`count` 도 함께 넘긴다** — 영어 복수형을 i18next 가 가르는 열쇠는 `count` 다((117), 2026-10-10).
|
|
109
|
+
`{{n}}` 만 넘기면 화면에 `1 questions` 가 뜬다(말귀 실측: 오늘의 듣기 카드 「국방 — 1 questions」).
|
|
110
|
+
글에 쓰는 것은 `{{n}}`(자리를 지키는 이름), 가르는 것은 `count` — 두 이름이 한 값을 받는다. */
|
|
111
|
+
const q = (n) => t('training.countQuestions', { n, count: n });
|
|
112
|
+
const s = (n) => t('training.countSentences', { n, count: n });
|
|
110
113
|
// 문항수가 정해지지 않으면(계산 불가) 가짜 기본값을 보이지 않도록 빈 값으로 둔다.
|
|
111
114
|
let countLabel = '';
|
|
112
115
|
try {
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import TrainingStep from './TrainingStep';
|
|
6
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
6
7
|
import TrainingWordCard from './TrainingWordCard';
|
|
7
8
|
import './TrainingStep.css';
|
|
8
9
|
import './TrainingStepCard.css';
|
|
@@ -108,11 +109,12 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
108
109
|
}
|
|
109
110
|
}, [playingAll, currentState, shown.length]);
|
|
110
111
|
const handleSelect = useCallback((item, i) => {
|
|
111
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
/* **한 번 고른 뒤에도 고칠 수 있다**(말귀 요청 (201), QA 버그 2026-10-12).
|
|
113
|
+
전에는 `!== 'active'` 로 막았는데, 고르는 순간 `onDone` 이 나가 단계가 넘어가므로
|
|
114
|
+
**자기 첫 고름에 자기가 막혔다** — 보기 셋에서 잘못 눌러도 바꿀 길이 없었다.
|
|
115
|
+
그 막이의 까닭(화면 선택은 바뀌는데 점수는 그대로)은 **아래에서 `onDone` 을 늘 보내
|
|
116
|
+
메운다** — 고치면 점수가 따라간다. 닿지 않은 단계만 막는다(`stepAnswerable`). */
|
|
117
|
+
if (!canAnswerStep(currentState))
|
|
116
118
|
return;
|
|
117
119
|
setSelectedIndex(i);
|
|
118
120
|
const payload = {
|
|
@@ -126,7 +128,10 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
126
128
|
onChange(payload);
|
|
127
129
|
}
|
|
128
130
|
catch (e) { }
|
|
129
|
-
|
|
131
|
+
/* **고친 답도 보낸다** — 이것이 옛 막이의 까닭(화면만 바뀌고 점수는 그대로)을 메우는
|
|
132
|
+
자리다. 러너가 차례를 `Math.max(prev.stepIndex, index + 1)` 로 올리므로 이미 지난
|
|
133
|
+
단계가 다시 알려도 **앞으로 안 뛴다**(재서 확인했다 — `stepAnswerable` 머리말). */
|
|
134
|
+
if (typeof onDone === 'function') {
|
|
130
135
|
try {
|
|
131
136
|
onDone(payload);
|
|
132
137
|
}
|
|
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import "./TrainingStep.css";
|
|
7
7
|
import TrainingStep from "./TrainingStep";
|
|
8
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
8
9
|
import TrainingStepTextBox from "./TrainingStepTextBox";
|
|
9
10
|
/** @public */
|
|
10
11
|
export default function TrainingStepConfirmWithText({ index, stepIndex, isLast = false, lockedHint, title, onDone, onChange, context, demoSelected }) {
|
|
@@ -51,27 +52,31 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
51
52
|
: { value: payload, __ts: Date.now() };
|
|
52
53
|
if (typeof onChange === 'function')
|
|
53
54
|
onChange(p);
|
|
54
|
-
|
|
55
|
+
/* **고친 답도 보낸다**((201) 을 이 자리까지, 말귀 실측 2026-10-12). 부르는 곳은 아래 답 길
|
|
56
|
+
셋뿐이고(소리 끝남에는 안 걸려 있다) 러너가 차례를 `Math.max` 로 올리므로 앞으로 안 뛴다. */
|
|
57
|
+
if (canAnswerStep(currentState) && typeof onDone === 'function')
|
|
55
58
|
onDone(p);
|
|
56
59
|
}, [onChange, currentState, onDone]);
|
|
57
60
|
const handleSelectFalse = useCallback(() => {
|
|
58
|
-
/*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/* **한 번 고른 뒤에도 고칠 수 있다** — (201) 과 같은 꼴이고 **이 자리는 한 가지가 더 나빴다**
|
|
62
|
+
(말귀 실측 2026-10-12, 소음 하 문장 듣기): `disabled` 가 `false` 라 **막혀 보이지도 않는다** —
|
|
63
|
+
누를 수 있게 생긴 단추를 눌렀는데 아무 일도 안 나면 쓰는 사람은 **자기가 잘못 눌렀다고 여기고
|
|
64
|
+
몇 번 더 누른다.** 그리고 여기는 `확인` 단추가 없어 *둘 중 하나를 누르는 것이 곧 답*이라,
|
|
65
|
+
여럿 고르기(`TrainingStepConfirm`)의 면제가 안 걸린다. 오늘의 듣기 **첫 칸**이다.
|
|
66
|
+
옛 막이의 까닭(화면만 바뀌고 점수는 그대로)은 `handleEndedLocal` 이 **고친 답도 보내** 메운다. */
|
|
67
|
+
if (!canAnswerStep(currentState))
|
|
63
68
|
return;
|
|
64
69
|
setSelectedAnswer(false);
|
|
65
70
|
handleEndedLocal(false);
|
|
66
71
|
}, [handleEndedLocal, currentState]);
|
|
67
72
|
const handleSelectTrue = useCallback(() => {
|
|
68
|
-
if (currentState
|
|
73
|
+
if (!canAnswerStep(currentState))
|
|
69
74
|
return;
|
|
70
75
|
setSelectedAnswer(true);
|
|
71
76
|
handleEndedLocal(true);
|
|
72
77
|
}, [handleEndedLocal, currentState]);
|
|
73
78
|
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, lockedHint: lockedHint, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
|
|
74
|
-
if (currentState
|
|
79
|
+
if (!canAnswerStep(currentState))
|
|
75
80
|
return;
|
|
76
81
|
setSelectedAnswer(i);
|
|
77
82
|
if (handleEndedLocal)
|
|
@@ -16,10 +16,28 @@ export function measureAudioDuration(url, opts) {
|
|
|
16
16
|
return new Promise((resolve, reject) => {
|
|
17
17
|
const ta = document.createElement('audio');
|
|
18
18
|
let done = false;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/**
|
|
20
|
+
* **소스를 비우는 바른 꼴**(말귀 요청 (301), 실측 2026-10-12).
|
|
21
|
+
*
|
|
22
|
+
* `src = ''` 는 *"소스 없음"* 이 아니라 **"쪽 주소를 소스로 삼아라"** 다. 그래서 뒷정리가
|
|
23
|
+
* 끝난 뒤 브라우저가 쪽을 한 번 더 받으러 가고 `error code 4 "Empty src attribute"` 를 낸다 —
|
|
24
|
+
* **실패가 아니라 뒷정리가 낸 오류**이고, `done` 깃발 덕에 사람에게는 아무 일도 없지만
|
|
25
|
+
* **문장을 불러올 때마다 한 번씩** 쌓인다. 말귀 실측:
|
|
26
|
+
*
|
|
27
|
+
* 17888 loadedmetadata src=F03023.mp3 ← 길이 재기는 성공
|
|
28
|
+
* 17888 abort src=(없음) ← 뒷정리 직후
|
|
29
|
+
* 17894 error code 4 "MEDIA_ELEMENT_ERROR: Empty src attribute"
|
|
30
|
+
*
|
|
31
|
+
* **값은 콘솔이 깨끗해지는 것만이 아니다** — 말귀가 밤새 소리를 못 쟀던 까닭 중 하나가 이 줄이다.
|
|
32
|
+
* 미디어 오류가 이미 쌓여 있으니 *"원래 이런가 보다"* 로 지나갔다. **가짜 오류는 진짜 오류를 가린다.**
|
|
33
|
+
*/
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
try {
|
|
36
|
+
ta.removeAttribute('src');
|
|
37
|
+
ta.load();
|
|
38
|
+
}
|
|
39
|
+
catch (_) { }
|
|
40
|
+
};
|
|
23
41
|
const finishOk = () => {
|
|
24
42
|
if (done)
|
|
25
43
|
return;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **지금 답을 고칠 수 있나** — 단계 상태 하나로 가른다.
|
|
3
|
+
*
|
|
4
|
+
* 이 결정이 여섯 자리에 `currentState !== 'active'` 로 적혀 있었다(`TrainingStepCard` ·
|
|
5
|
+
* `TrainingStepConfirm`×2 · `TrainingStepConfirmWithText`×3 · `TrainingStepCardOrder` ·
|
|
6
|
+
* `TrainingStepOrderList`). 그 막이는 **까닭이 있어 들어왔다** — 단추는 `!== 'disabled'` 로 열려
|
|
7
|
+
* 있는데 기록은 `=== 'active'` 일 때만 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
|
|
8
|
+
*
|
|
9
|
+
* 그런데 그 막이가 **자기 첫 고름에 자기를 막는다**(말귀 요청 (201), QA 버그 보고 2026-10-12):
|
|
10
|
+
* 고르면 `onDone` 이 나가고 단계가 넘어가 `currentState` 가 `'done'` 이 된다 — 그 뒤로는
|
|
11
|
+
* **잘못 눌러도 바꿀 길이 없다.** 보기 셋에서 손이 미끄러지면 그 판은 그대로 끝난다.
|
|
12
|
+
*
|
|
13
|
+
* **둘 다 틀렸고 바라는 것은 셋째다 — 선택도 바뀌고 점수도 따라간다.** 그래서 가름을
|
|
14
|
+
* *"지금 서 있는 단계인가"* 에서 *"아직 닿지 않은 단계인가"* 로 옮긴다:
|
|
15
|
+
*
|
|
16
|
+
* active 고를 수 있다 — 처음 고르는 자리
|
|
17
|
+
* done 고칠 수 있다 — 고치면 `onDone` 이 다시 나가 점수가 따라간다
|
|
18
|
+
* disabled 막는다 — 아직 닿지 않았거나 되돌아본 판이다
|
|
19
|
+
*
|
|
20
|
+
* **두 번째 `onDone` 이 앞으로 안 뛰는 것을 확인했다**(그래서 이 고침이 안전하다):
|
|
21
|
+
* 러너가 `stepIndex: Math.max(prev.stepIndex, index + 1)` 로 올리므로, 이미 지난 단계가 다시
|
|
22
|
+
* 알려도 차례는 그대로다(`TrainingPagePopup.tsx`). 재지 않고 고쳤으면 **고친 답이 다음 단계를
|
|
23
|
+
* 한 칸 더 밀** 수도 있었다.
|
|
24
|
+
*
|
|
25
|
+
* **지금 쓰는 곳은 `TrainingStepCard` 하나다** — QA 가 올린 그 자리다. 나머지 넷도 같은 꼴로
|
|
26
|
+
* 보이지만(고르면 넘어가고 그 뒤 못 고친다) **화면에서 재 보지 않고 넷을 함께 바꾸지 않는다** —
|
|
27
|
+
* 순서 매기기(`CardOrder`·`OrderList`)는 "고침" 의 뜻이 다를 수 있고, 여럿 고르기(`Confirm`)는
|
|
28
|
+
* `확인` 단추가 이미 그 틈을 메운다. 셈은 적어 두었으니 그 넷은 **재고 나서** 옮긴다.
|
|
29
|
+
*/
|
|
30
|
+
export type 단계상태 = 'disabled' | 'done' | 'active';
|
|
31
|
+
/** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
|
|
32
|
+
export declare function canAnswerStep(state: 단계상태 | undefined): boolean;
|
|
@@ -168,9 +168,13 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
168
168
|
noiseAudioRef.current = noiseEl;
|
|
169
169
|
return () => {
|
|
170
170
|
cancelNoiseFade();
|
|
171
|
+
/* **`src = ''` 가 아니다** — 그것은 "쪽 주소를 소스로 삼아라" 라서 `error code 4` 를 낸다((301)).
|
|
172
|
+
소음은 훈련마다 서고 걷히니 그 가짜 오류도 그만큼 쌓인다 — 말귀가 소리를 재려는데 콘솔이
|
|
173
|
+
이미 미디어 오류로 덮여 있던 자리다. */
|
|
171
174
|
try {
|
|
172
175
|
noiseEl.pause();
|
|
173
|
-
noiseEl.src
|
|
176
|
+
noiseEl.removeAttribute('src');
|
|
177
|
+
noiseEl.load();
|
|
174
178
|
}
|
|
175
179
|
catch (e) { }
|
|
176
180
|
noiseAudioRef.current = null;
|
|
@@ -21,7 +21,7 @@ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchT
|
|
|
21
21
|
ihab 은 **흰 판을 마스크로 쓴다**(사용자 지시 2026-09-30: 글자색을 입힌다) — 그쪽은
|
|
22
22
|
`WordTable.css` 가 제 곁의 파일을 직접 가리키므로 여기서 안 들여온다. */
|
|
23
23
|
import 말귀로고 from '../logo/Logo_speechear_kr.svg';
|
|
24
|
-
import { WORD_TABLE_PAPER, gridFor, CUT_CLASS,
|
|
24
|
+
import { WORD_TABLE_PAPER, gridFor, CUT_CLASS, posLabels, wordTypeLabels, expandSlashLabel, dotDashStyle, REF_W_BOTH, REF_W_TEXT_ONLY, REF_W_IMAGE_ONLY, REF_H_BOTH, REF_H_TEXT_ONLY, REF_H_IMAGE_ONLY, } from './wordTableLayout';
|
|
25
25
|
import { useFitScale } from './useFitScale';
|
|
26
26
|
import { claimSound } from './Training/currentSound';
|
|
27
27
|
import { playOneShot } from './Training/oneShotSound';
|
|
@@ -78,9 +78,9 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
78
78
|
arr.push(t('wordTable.info.syllables', { n: wordInfo.syllables, defaultValue: '음절 수: {{n}}음절' }));
|
|
79
79
|
// 축약 코드를 전체 표기로 — 복합값('명/관' → '명사/관형사')도 각각 펼친다(단어 관리 표와 동일 규칙).
|
|
80
80
|
if (wordInfo.pos)
|
|
81
|
-
arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos,
|
|
81
|
+
arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, posLabels(t)), defaultValue: '품사: {{v}}' }));
|
|
82
82
|
if (wordInfo.wordType)
|
|
83
|
-
arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType,
|
|
83
|
+
arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, wordTypeLabels(t)), defaultValue: '유형: {{v}}' }));
|
|
84
84
|
return arr.length ? arr : null;
|
|
85
85
|
}, [wordInfo, t]);
|
|
86
86
|
const 소리열쇠 = pron || label;
|
|
@@ -167,19 +167,6 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
167
167
|
것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
|
|
168
168
|
ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => { e.stopPropagation(); 정보톡.touchPress(); } }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
|
|
169
169
|
}
|
|
170
|
-
// ── WordTable (표 디자인) ──────────────────────────────────────
|
|
171
|
-
/* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
|
|
172
|
-
const 품사열쇠 = {
|
|
173
|
-
'명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
|
|
174
|
-
'조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
|
|
175
|
-
};
|
|
176
|
-
const 어종열쇠 = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
|
|
177
|
-
function 품사이름(t) {
|
|
178
|
-
return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, 품사열쇠[k] ? t(`wordTable.pos.${품사열쇠[k]}`, { defaultValue: v }) : v]));
|
|
179
|
-
}
|
|
180
|
-
function 어종이름(t) {
|
|
181
|
-
return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, 어종열쇠[k] ? t(`wordSearch.filter.type.${어종열쇠[k]}`, { defaultValue: v }) : v]));
|
|
182
|
-
}
|
|
183
170
|
/**
|
|
184
171
|
* 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
|
|
185
172
|
* 칸 그리드와 단어 셀을 그린다. 컨트롤(필터·저장·인쇄)은 포함하지 않는다.
|
|
@@ -66,6 +66,39 @@ export declare const CUT_CLASS: Record<WordTableCutLine, string>;
|
|
|
66
66
|
export declare const POS_LABEL: Record<string, string>;
|
|
67
67
|
/** 어종(단어 유형) 약호 → 전체 표기. 데이터의 Class 컬럼은 축약형('고유'/'한자'/'외래'/'혼종')을 쓴다. */
|
|
68
68
|
export declare const WORD_TYPE_LABEL: Record<string, string>;
|
|
69
|
+
/** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
|
|
70
|
+
type 번역 = (k: string, o: {
|
|
71
|
+
defaultValue: string;
|
|
72
|
+
}) => string;
|
|
73
|
+
/**
|
|
74
|
+
* **품사 약호 → 지금 언어의 이름**(`{ '명': 'Noun', … }`). `expandSlashLabel` 에 그대로 넘긴다.
|
|
75
|
+
*
|
|
76
|
+
* 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
export declare function posLabels(t: 번역): Record<string, string>;
|
|
80
|
+
/**
|
|
81
|
+
* **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
84
|
+
export declare function wordTypeLabels(t: 번역): Record<string, string>;
|
|
85
|
+
/**
|
|
86
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
87
|
+
*
|
|
88
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
89
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
90
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
91
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
92
|
+
*
|
|
93
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
94
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
95
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
98
|
+
export declare const WORD_LABEL_I18N_KEYS: {
|
|
99
|
+
readonly pos: Record<string, string>;
|
|
100
|
+
readonly wordType: Record<string, string>;
|
|
101
|
+
};
|
|
69
102
|
/**
|
|
70
103
|
* 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
|
|
71
104
|
* 라벨맵에 없는 조각은 원문 그대로 둔다. 빈 문자열은 빈 문자열 그대로 반환.
|
|
@@ -78,3 +111,4 @@ export declare const REF_W_IMAGE_ONLY = 280;
|
|
|
78
111
|
export declare const REF_H_BOTH = 400;
|
|
79
112
|
export declare const REF_H_TEXT_ONLY = 120;
|
|
80
113
|
export declare const REF_H_IMAGE_ONLY = 280;
|
|
114
|
+
export {};
|