@ihabdevteam/core 0.135.0 → 0.137.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 +128 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +3 -3
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +38 -7
- package/dist/packages/core/src/components/Alert.js +1 -1
- package/dist/packages/core/src/components/Breadcrumb.js +1 -0
- 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 +5 -2
- package/dist/packages/core/src/components/Icon.js +4 -1
- package/dist/packages/core/src/components/IconGridMenu.js +25 -22
- package/dist/packages/core/src/components/LandingHeader.d.ts +1 -1
- package/dist/packages/core/src/components/LandingHeader.js +5 -1
- package/dist/packages/core/src/components/PageTitle.js +1 -0
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/Panel.css +3 -0
- package/dist/packages/core/src/components/Panel.d.ts +11 -1
- package/dist/packages/core/src/components/Panel.js +3 -3
- package/dist/packages/core/src/components/PanelRow.js +8 -1
- package/dist/packages/core/src/components/PassCard/PassCardBgIHAB.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgLeftColorGrid.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgSoundMove.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgSoundMoveV1.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTalk.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTalkV1.js +1 -0
- package/dist/packages/core/src/components/PassCard/PassCardBgTwoCircle.js +1 -0
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +8 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
- package/dist/packages/core/src/components/Popup/Popup.css +19 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +14 -1
- package/dist/packages/core/src/components/Popup/Popup.js +6 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +6 -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 +7 -1
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDark.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDeep.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDiagonal.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgDots.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgInstagram.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgMain.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgNaver.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgOutline.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgRadial.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoft.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSoftSub.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSub.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgSuccess.js +1 -0
- package/dist/packages/core/src/components/PromoBannerBg/PromoBannerBgWarm.js +1 -0
- package/dist/packages/core/src/components/Rating.js +3 -1
- 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/TextField.js +3 -1
- package/dist/packages/core/src/components/Tooltip/DescriptionTooltip.js +1 -0
- 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 +15 -7
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +28 -1
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +10 -3
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.d.ts +0 -1
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +16 -1
- 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 +21 -4
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
- package/dist/packages/core/src/components/Training/currentSound.d.ts +5 -0
- package/dist/packages/core/src/components/Training/currentSound.js +13 -0
- 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/Training/trainingPopupUtils.js +12 -1
- package/dist/packages/core/src/components/Training/useNoiseFade.js +4 -0
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +5 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- 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/WordTable.js +19 -5
- package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
- 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/useFontScale.js +13 -4
- package/dist/packages/core/src/hooks/useIcon.js +3 -0
- package/dist/packages/core/src/hooks/useLucideIcon.js +3 -0
- package/dist/packages/core/src/hooks/useMediaQuery.d.ts +2 -1
- package/dist/packages/core/src/hooks/useMediaQuery.js +37 -8
- package/dist/packages/core/src/hooks/usePagedList.js +1 -0
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
- package/dist/packages/core/src/hooks/useTrainingTypeLabels.js +1 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +1 -0
- package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.js +18 -3
- package/dist/packages/core/src/i18n/locales/en.json +42 -4
- package/dist/packages/core/src/i18n/locales/ko.json +42 -4
- package/dist/packages/core/src/utils/date.js +26 -5
- package/dist/packages/core/src/utils/errorMessage.js +7 -1
- 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/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
|
@@ -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 = '') {
|
|
@@ -43,17 +43,18 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
43
43
|
if (!wordInfo)
|
|
44
44
|
return null;
|
|
45
45
|
const arr = [];
|
|
46
|
+
// 지금 언어로 — 템플릿 글자·약호 이름표가 한국어로 박혀 영어 화면에도 "난이도: 2등급 / 품사: 명사" 가 떴다
|
|
46
47
|
if (wordInfo.grade)
|
|
47
|
-
arr.push(
|
|
48
|
+
arr.push(t('wordTable.info.grade', { n: wordInfo.grade, defaultValue: '난이도: {{n}}등급' }));
|
|
48
49
|
if (wordInfo.syllables)
|
|
49
|
-
arr.push(
|
|
50
|
+
arr.push(t('wordTable.info.syllables', { n: wordInfo.syllables, defaultValue: '음절 수: {{n}}음절' }));
|
|
50
51
|
// 축약 코드를 전체 표기로 — 복합값('명/관' → '명사/관형사')도 각각 펼친다(단어 관리 표와 동일 규칙).
|
|
51
52
|
if (wordInfo.pos)
|
|
52
|
-
arr.push(
|
|
53
|
+
arr.push(t('wordTable.info.pos', { v: expandSlashLabel(wordInfo.pos, 품사이름(t)), defaultValue: '품사: {{v}}' }));
|
|
53
54
|
if (wordInfo.wordType)
|
|
54
|
-
arr.push(
|
|
55
|
+
arr.push(t('wordTable.info.wordType', { v: expandSlashLabel(wordInfo.wordType, 어종이름(t)), defaultValue: '유형: {{v}}' }));
|
|
55
56
|
return arr.length ? arr : null;
|
|
56
|
-
}, [wordInfo]);
|
|
57
|
+
}, [wordInfo, t]);
|
|
57
58
|
useEffect(() => {
|
|
58
59
|
const resolver = getTTSUrlResolver();
|
|
59
60
|
const ttsText = pron || label;
|
|
@@ -128,6 +129,19 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
|
|
|
128
129
|
것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
|
|
129
130
|
ariaLabel: t('wordTable.wordInfo', '{{word}} 낱말 정보', { word: label }), onClick: (e) => e.stopPropagation() }), infoAnchor && infoTooltipItems && (_jsx(DescriptionTooltip, { anchorRect: infoAnchor, items: infoTooltipItems, showIndex: false }))] }))] }));
|
|
130
131
|
}
|
|
132
|
+
// ── WordTable (표 디자인) ──────────────────────────────────────
|
|
133
|
+
/* 품사·어종 약호 → 지금 언어의 이름. 사전에 없으면 원본 한국어 이름표(POS_LABEL·WORD_TYPE_LABEL) */
|
|
134
|
+
const 품사열쇠 = {
|
|
135
|
+
'명': 'noun', '동': 'verb', '형': 'adjective', '부': 'adverb', '수': 'numeral', '관': 'determiner', '감': 'interjection',
|
|
136
|
+
'조': 'particle', '접': 'affix', '의': 'dependentNoun', '대': 'pronoun', '보동': 'auxVerb', '보형': 'auxAdjective',
|
|
137
|
+
};
|
|
138
|
+
const 어종열쇠 = { '고유': 'native', '한자': 'sino', '외래': 'foreign', '혼종': 'mixed' };
|
|
139
|
+
function 품사이름(t) {
|
|
140
|
+
return Object.fromEntries(Object.entries(POS_LABEL).map(([k, v]) => [k, 품사열쇠[k] ? t(`wordTable.pos.${품사열쇠[k]}`, { defaultValue: v }) : v]));
|
|
141
|
+
}
|
|
142
|
+
function 어종이름(t) {
|
|
143
|
+
return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, 어종열쇠[k] ? t(`wordSearch.filter.type.${어종열쇠[k]}`, { defaultValue: v }) : v]));
|
|
144
|
+
}
|
|
131
145
|
/**
|
|
132
146
|
* 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,
|
|
133
147
|
* 칸 그리드와 단어 셀을 그린다. 컨트롤(필터·저장·인쇄)은 포함하지 않는다.
|
|
@@ -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
|
/**
|
|
@@ -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
|
}
|
|
@@ -5,9 +5,9 @@ export default function useFontScale({ storageKey = 'fontScale',
|
|
|
5
5
|
// 기본값을 중앙화: Training/Tooltip 텍스트 박스 공통값
|
|
6
6
|
baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4, } = {}) {
|
|
7
7
|
const clamp = (v) => Math.max(minScale, Math.min(maxScale, Number(v)));
|
|
8
|
-
const
|
|
8
|
+
const 읽기 = (key) => {
|
|
9
9
|
try {
|
|
10
|
-
const raw = localStorage.getItem(
|
|
10
|
+
const raw = localStorage.getItem(key);
|
|
11
11
|
const parsed = raw ? Number(raw) : 1;
|
|
12
12
|
// 손상된/비숫자 저장값(Number(raw) === NaN)이 그대로 초기값이 되면 scale이 NaN으로
|
|
13
13
|
// 고정되어 canIncrease/canDecrease 비교가 항상 false가 되고 fontSize/lineHeight도
|
|
@@ -17,9 +17,18 @@ baseFont = 20, minScale = 0.8, maxScale = 1.4, step = 0.1, lineHeightRatio = 1.4
|
|
|
17
17
|
return Number.isFinite(parsed) ? clamp(parsed) : clamp(1);
|
|
18
18
|
}
|
|
19
19
|
catch (e) {
|
|
20
|
-
|
|
20
|
+
// 저장소를 못 읽어도 범위는 지킨다(minScale 이 1 보다 크면 1 은 범위 밖이다)
|
|
21
|
+
return clamp(1);
|
|
21
22
|
}
|
|
22
|
-
}
|
|
23
|
+
};
|
|
24
|
+
const [scale, setScaleRaw] = useState(() => 읽기(storageKey));
|
|
25
|
+
/* 열쇠가 바뀌면 **그 열쇠의 저장값** 으로 — 전에는 앞 열쇠의 값을 들고 있다가 아래 쓰기가 새 열쇠의 저장값을 덮었다.
|
|
26
|
+
렌더 중에 맞추므로 쓰기 effect 는 새 값으로만 돈다. */
|
|
27
|
+
const [읽은열쇠, set읽은열쇠] = useState(storageKey);
|
|
28
|
+
if (읽은열쇠 !== storageKey) {
|
|
29
|
+
set읽은열쇠(storageKey);
|
|
30
|
+
setScaleRaw(읽기(storageKey));
|
|
31
|
+
}
|
|
23
32
|
useEffect(() => {
|
|
24
33
|
try {
|
|
25
34
|
localStorage.setItem(storageKey, String(scale));
|
|
@@ -42,6 +42,9 @@ export default function useIcon(icon) {
|
|
|
42
42
|
normalized = icon;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
// 다른 파일로 바뀌면 앞 아이콘의 판정·SVG 를 걷는다 — 안 걷으면 새 파일을 받는 동안 앞 아이콘이 그대로 그려졌다
|
|
46
|
+
setMode(null);
|
|
47
|
+
setSvgText(null);
|
|
45
48
|
setIconToUse(normalized);
|
|
46
49
|
}, [icon]);
|
|
47
50
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
@@ -20,6 +20,9 @@ function toRawKey(icon) {
|
|
|
20
20
|
return null;
|
|
21
21
|
if (icon.includes('/assets/'))
|
|
22
22
|
return null;
|
|
23
|
+
// 그림 파일(.png·.jpg 등)은 아이콘 이름이 아니다 — 이름으로 찾아 개발 모드에 헛 경고를 냈다
|
|
24
|
+
if (/\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i.test(lower))
|
|
25
|
+
return null;
|
|
23
26
|
const baseFile = icon.split('/').pop() || '';
|
|
24
27
|
// 주의: lucide 아이콘명 규약("trash-2", "edit-2", "link-2" 등)은 끝의 숫자가
|
|
25
28
|
// "-2/-3" 변형 아이콘을 가리키는 유의미한 부분이라 잘라내면 안 됨(예: trash-2 ≠ trash).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
1
2
|
/**
|
|
2
3
|
* 미디어쿼리 결과를 강제로 덮어쓰기 위한 컨텍스트.
|
|
3
4
|
* 랜딩페이지 폰 목업처럼 데스크탑 뷰포트에서 모바일 화면을 재현해야 할 때,
|
|
@@ -12,6 +13,6 @@ export interface MediaQueryOverride {
|
|
|
12
13
|
mobile?: boolean;
|
|
13
14
|
}
|
|
14
15
|
/** @public */
|
|
15
|
-
export declare const MediaQueryOverrideContext:
|
|
16
|
+
export declare const MediaQueryOverrideContext: React.Context<MediaQueryOverride | null>;
|
|
16
17
|
/** @public */
|
|
17
18
|
export default function useMediaQuery(query: string): boolean;
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { useCallback, useEffect, useState, useContext, createContext } from 'react';
|
|
3
4
|
/** @public */
|
|
4
5
|
export const MediaQueryOverrideContext = createContext(null);
|
|
5
6
|
// '(max-width: 767px)' / '(max-width: 768px)' 같은 모바일 판정 쿼리인지 추정
|
|
6
7
|
function isMobileQuery(query) {
|
|
7
8
|
return /max-width:\s*7[0-9][0-9]px/.test(query);
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const getMatches = (q) => {
|
|
11
|
+
if (typeof window === 'undefined' || !window.matchMedia)
|
|
12
|
+
return false;
|
|
13
|
+
return window.matchMedia(q).matches;
|
|
14
|
+
};
|
|
15
|
+
/* React 18+ — 외부 저장소 구독으로 읽는다. **서버 스냅숏은 false** 라, 서버에서 그린 HTML 을 붙일 때(kscan·Next) 첫 렌더가
|
|
16
|
+
서버와 같고 붙인 뒤 곧바로 실제 값으로 다시 그린다. 전에는 첫 렌더에서 matchMedia 를 읽어 모바일 기기에서 hydration
|
|
17
|
+
불일치로 트리를 통째로 다시 만들었다. 서버 렌더가 아닌 첫 렌더는 그대로 실제 값이다. */
|
|
18
|
+
function useMatchesStore(query) {
|
|
19
|
+
const subscribe = useCallback((onChange) => {
|
|
13
20
|
if (typeof window === 'undefined' || !window.matchMedia)
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
return () => { };
|
|
22
|
+
const mql = window.matchMedia(query);
|
|
23
|
+
if (mql.addEventListener)
|
|
24
|
+
mql.addEventListener('change', onChange);
|
|
25
|
+
else if (mql.addListener)
|
|
26
|
+
mql.addListener(onChange);
|
|
27
|
+
return () => {
|
|
28
|
+
if (mql.removeEventListener)
|
|
29
|
+
mql.removeEventListener('change', onChange);
|
|
30
|
+
else if (mql.removeListener)
|
|
31
|
+
mql.removeListener(onChange);
|
|
32
|
+
};
|
|
33
|
+
}, [query]);
|
|
34
|
+
return React.useSyncExternalStore(subscribe, () => getMatches(query), () => false);
|
|
35
|
+
}
|
|
36
|
+
/* React 16.8·17 — useSyncExternalStore 가 없다. 예전 방식 그대로. */
|
|
37
|
+
function useMatchesState(query) {
|
|
17
38
|
const [matches, setMatches] = useState(getMatches(query));
|
|
18
39
|
// query가 바뀌면 새 matchMedia 결과로 즉시 동기화(구독 시작 전 값이 stale할 수 있음) —
|
|
19
40
|
// window.matchMedia(외부 시스템) 구독의 표준 패턴이다
|
|
@@ -37,6 +58,14 @@ export default function useMediaQuery(query) {
|
|
|
37
58
|
};
|
|
38
59
|
}, [query]);
|
|
39
60
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
61
|
+
return matches;
|
|
62
|
+
}
|
|
63
|
+
// 모듈을 불러올 때 한 번 정한다 — 렌더마다 같은 훅을 부르므로 훅 순서 규칙을 어기지 않는다
|
|
64
|
+
const useMatches = typeof React.useSyncExternalStore === 'function' ? useMatchesStore : useMatchesState;
|
|
65
|
+
/** @public */
|
|
66
|
+
export default function useMediaQuery(query) {
|
|
67
|
+
const override = useContext(MediaQueryOverrideContext);
|
|
68
|
+
const matches = useMatches(query);
|
|
40
69
|
// 오버라이드가 있고 모바일 판정 쿼리이면 강제값 사용
|
|
41
70
|
if (override && typeof override.mobile === 'boolean' && isMobileQuery(query)) {
|
|
42
71
|
return override.mobile;
|
|
@@ -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)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { CAPABILITY, generateRuntimeItems, precomputeCommonCombinations, } from '../utils/wordTrainingRuntime';
|
|
4
4
|
import { valueToJamos, syllableToFilter, NO_CODA } from './useWordTrainingFilter';
|
|
5
5
|
/** 선택된 단어 구성을 실제 매칭용 CompositionFilter로 변환. 선택 없으면 null.
|
|
@@ -36,6 +36,9 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
|
|
|
36
36
|
// 배열인 이쪽만 빠져 있었다. effect 안에서 어차피 정렬해 문자열로 쓰고 있으니
|
|
37
37
|
// 그 문자열을 의존성으로 삼는다. 내용이 같으면 다시 만들지 않는다.
|
|
38
38
|
const gradeKey = gradeFilter.length > 0 ? gradeFilter.slice().sort().join(',') : '';
|
|
39
|
+
/* types 도 **내용** 으로 — 만드는 일은 어떤 유형이 있느냐(key)에만 달려 있다. 빠져 있어 늦게 채워진 목록([] → [A,B])으로는
|
|
40
|
+
끝까지 아무것도 안 만들었다. 이름·색·부제는 아래에서 최신 types 로 덧입혀, 언어를 바꿔도 다시 뽑지 않고 이름만 따라간다. */
|
|
41
|
+
const typesKey = types.map(t => t.key).join('|');
|
|
39
42
|
// 단어풀 갱신 시 사전 계산 (백그라운드 fire-and-forget)
|
|
40
43
|
useEffect(() => {
|
|
41
44
|
if (poolVersion > 0) {
|
|
@@ -100,7 +103,19 @@ export function useWordTrainingDataGeneration(types, difficulty, contrastCount,
|
|
|
100
103
|
}
|
|
101
104
|
})();
|
|
102
105
|
return () => { cancelled = true; };
|
|
103
|
-
}, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
106
|
+
}, [difficulty, contrastCount, cho, jung, jong, syllable, excludeNoImage, sameStructureOnly, phonemeView, poolVersion, gradeKey, typesKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
104
107
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
105
|
-
|
|
108
|
+
// 이름·색·부제는 지금 types 로 — 만든 때의 label 을 들고 있어 언어를 바꿔도 단추 제목이 옛 언어로 남았다
|
|
109
|
+
// 부르는 쪽이 types 를 매번 새 배열로 줘도 결과 객체가 그대로이도록 **내용** 으로 기억한다
|
|
110
|
+
const typesLabelKey = JSON.stringify(types.map(t => [t.key, t.label, t.color, t.subTitle]));
|
|
111
|
+
const labeled = useMemo(() => {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [key, v] of Object.entries(perTypeData)) {
|
|
114
|
+
const t = types.find(x => x.key === key);
|
|
115
|
+
out[key] = v && t ? { ...v, name: t.label, iconColor: t.color, subTitle: t.subTitle } : v;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
+
}, [perTypeData, typesLabelKey]);
|
|
120
|
+
return { perTypeData: labeled, generating };
|
|
106
121
|
}
|
|
@@ -83,7 +83,8 @@
|
|
|
83
83
|
"velar": "ɰ-glide"
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
|
-
"unknownError": "Unknown error"
|
|
86
|
+
"unknownError": "Unknown error",
|
|
87
|
+
"networkError": "The network connection is unstable. Check your connection and try again."
|
|
87
88
|
},
|
|
88
89
|
"calendar": {
|
|
89
90
|
"rangeSelect": "Select period",
|
|
@@ -150,7 +151,8 @@
|
|
|
150
151
|
"logoMalgwi": "Malgwi logo",
|
|
151
152
|
"logoManager": "Malgwi Manager logo",
|
|
152
153
|
"menuOpen": "Open menu",
|
|
153
|
-
"utilMenu": "Utility menu"
|
|
154
|
+
"utilMenu": "Utility menu",
|
|
155
|
+
"menuClose": "Close menu"
|
|
154
156
|
},
|
|
155
157
|
"nav": {
|
|
156
158
|
"mainMenu": "Main menu",
|
|
@@ -376,6 +378,7 @@
|
|
|
376
378
|
"listenButton": "Listen",
|
|
377
379
|
"playSound": "Play Sound",
|
|
378
380
|
"playAll": "Play All",
|
|
381
|
+
"selectInOrder": "Select the cards in the order you heard.",
|
|
379
382
|
"stop": "Stop",
|
|
380
383
|
"preview": "Preview",
|
|
381
384
|
"next": "Next",
|
|
@@ -416,7 +419,17 @@
|
|
|
416
419
|
"prev": "Previous",
|
|
417
420
|
"listenLabel": "Listen",
|
|
418
421
|
"sourcePlay": "Play source {{n}}",
|
|
419
|
-
"listen": "Listen"
|
|
422
|
+
"listen": "Listen",
|
|
423
|
+
"audioStatus": {
|
|
424
|
+
"idle": "Idle",
|
|
425
|
+
"resolving": "Resolving…",
|
|
426
|
+
"checking_exists": "Checking file…",
|
|
427
|
+
"getting_signed_url": "Getting URL…",
|
|
428
|
+
"downloading": "Downloading…",
|
|
429
|
+
"measuring_duration": "Measuring length…",
|
|
430
|
+
"ready": "Ready",
|
|
431
|
+
"error": "Error"
|
|
432
|
+
}
|
|
420
433
|
},
|
|
421
434
|
"trainingSettings": {
|
|
422
435
|
"totalCount": " training bundles",
|
|
@@ -802,6 +815,27 @@
|
|
|
802
815
|
"discardTitle": "Discard this scoring?",
|
|
803
816
|
"discardMessage": "You marked {{count}} cells but have not saved. Leaving discards them.",
|
|
804
817
|
"discardConfirm": "Discard and leave"
|
|
818
|
+
},
|
|
819
|
+
"info": {
|
|
820
|
+
"grade": "Grade: {{n}}",
|
|
821
|
+
"syllables": "Syllables: {{n}}",
|
|
822
|
+
"pos": "Part of speech: {{v}}",
|
|
823
|
+
"wordType": "Type: {{v}}"
|
|
824
|
+
},
|
|
825
|
+
"pos": {
|
|
826
|
+
"noun": "Noun",
|
|
827
|
+
"verb": "Verb",
|
|
828
|
+
"adjective": "Adjective",
|
|
829
|
+
"adverb": "Adverb",
|
|
830
|
+
"numeral": "Numeral",
|
|
831
|
+
"determiner": "Determiner",
|
|
832
|
+
"interjection": "Interjection",
|
|
833
|
+
"particle": "Particle",
|
|
834
|
+
"affix": "Affix",
|
|
835
|
+
"dependentNoun": "Dependent noun",
|
|
836
|
+
"pronoun": "Pronoun",
|
|
837
|
+
"auxVerb": "Auxiliary verb",
|
|
838
|
+
"auxAdjective": "Auxiliary adjective"
|
|
805
839
|
}
|
|
806
840
|
},
|
|
807
841
|
"wordSearch": {
|
|
@@ -968,7 +1002,11 @@
|
|
|
968
1002
|
},
|
|
969
1003
|
"addNew": "Add New Device",
|
|
970
1004
|
"selectFromList": "Select from List",
|
|
971
|
-
"removeDevice": "Remove"
|
|
1005
|
+
"removeDevice": "Remove",
|
|
1006
|
+
"type": {
|
|
1007
|
+
"hearingAid": "Hearing Aid",
|
|
1008
|
+
"cochlearImplant": "Cochlear Implant"
|
|
1009
|
+
}
|
|
972
1010
|
},
|
|
973
1011
|
"manager": {
|
|
974
1012
|
"stat": {
|
|
@@ -81,7 +81,8 @@
|
|
|
81
81
|
"velar": "ɰ활음"
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
|
-
"unknownError": "알 수 없는 오류"
|
|
84
|
+
"unknownError": "알 수 없는 오류",
|
|
85
|
+
"networkError": "네트워크 연결이 불안정합니다. 연결 상태를 확인한 뒤 다시 시도해 주세요."
|
|
85
86
|
},
|
|
86
87
|
"calendar": {
|
|
87
88
|
"rangeSelect": "기간 선택",
|
|
@@ -148,7 +149,8 @@
|
|
|
148
149
|
"logoMalgwi": "말귀 로고",
|
|
149
150
|
"logoManager": "말귀매니저 로고",
|
|
150
151
|
"menuOpen": "메뉴 열기",
|
|
151
|
-
"utilMenu": "유틸리티 메뉴"
|
|
152
|
+
"utilMenu": "유틸리티 메뉴",
|
|
153
|
+
"menuClose": "메뉴 닫기"
|
|
152
154
|
},
|
|
153
155
|
"nav": {
|
|
154
156
|
"mainMenu": "메인 메뉴",
|
|
@@ -374,6 +376,7 @@
|
|
|
374
376
|
"listenButton": "문장 듣기",
|
|
375
377
|
"playSound": "소리 듣기",
|
|
376
378
|
"playAll": "모두 듣기",
|
|
379
|
+
"selectInOrder": "들은 순서대로 카드를 선택하세요.",
|
|
377
380
|
"stop": "정지",
|
|
378
381
|
"preview": "미리 듣기",
|
|
379
382
|
"next": "다음",
|
|
@@ -414,7 +417,17 @@
|
|
|
414
417
|
"prev": "이전",
|
|
415
418
|
"listenLabel": "문장 듣기",
|
|
416
419
|
"sourcePlay": "소스 {{n}} 재생",
|
|
417
|
-
"listen": "듣기"
|
|
420
|
+
"listen": "듣기",
|
|
421
|
+
"audioStatus": {
|
|
422
|
+
"idle": "대기",
|
|
423
|
+
"resolving": "리소스 매핑 중…",
|
|
424
|
+
"checking_exists": "파일 존재 확인…",
|
|
425
|
+
"getting_signed_url": "서명 URL 조회…",
|
|
426
|
+
"downloading": "다운로드 중…",
|
|
427
|
+
"measuring_duration": "오디오 길이 측정…",
|
|
428
|
+
"ready": "준비 완료",
|
|
429
|
+
"error": "오류 발생"
|
|
430
|
+
}
|
|
418
431
|
},
|
|
419
432
|
"trainingSettings": {
|
|
420
433
|
"totalCount": "개의 훈련 묶음",
|
|
@@ -800,6 +813,27 @@
|
|
|
800
813
|
"discardTitle": "매긴 채점을 버릴까요?",
|
|
801
814
|
"discardMessage": "{{count}}칸을 매겼지만 아직 저장하지 않았습니다. 나가면 사라집니다.",
|
|
802
815
|
"discardConfirm": "버리고 나가기"
|
|
816
|
+
},
|
|
817
|
+
"info": {
|
|
818
|
+
"grade": "난이도: {{n}}등급",
|
|
819
|
+
"syllables": "음절 수: {{n}}음절",
|
|
820
|
+
"pos": "품사: {{v}}",
|
|
821
|
+
"wordType": "유형: {{v}}"
|
|
822
|
+
},
|
|
823
|
+
"pos": {
|
|
824
|
+
"noun": "명사",
|
|
825
|
+
"verb": "동사",
|
|
826
|
+
"adjective": "형용사",
|
|
827
|
+
"adverb": "부사",
|
|
828
|
+
"numeral": "수사",
|
|
829
|
+
"determiner": "관형사",
|
|
830
|
+
"interjection": "감탄사",
|
|
831
|
+
"particle": "조사",
|
|
832
|
+
"affix": "접사",
|
|
833
|
+
"dependentNoun": "의존명사",
|
|
834
|
+
"pronoun": "대명사",
|
|
835
|
+
"auxVerb": "보조동사",
|
|
836
|
+
"auxAdjective": "보조형용사"
|
|
803
837
|
}
|
|
804
838
|
},
|
|
805
839
|
"wordSearch": {
|
|
@@ -969,7 +1003,11 @@
|
|
|
969
1003
|
},
|
|
970
1004
|
"addNew": "새 기기 추가",
|
|
971
1005
|
"selectFromList": "목록에서 선택",
|
|
972
|
-
"removeDevice": "착용 해제"
|
|
1006
|
+
"removeDevice": "착용 해제",
|
|
1007
|
+
"type": {
|
|
1008
|
+
"hearingAid": "보청기",
|
|
1009
|
+
"cochlearImplant": "인공와우"
|
|
1010
|
+
}
|
|
973
1011
|
},
|
|
974
1012
|
"manager": {
|
|
975
1013
|
"stat": {
|