@ihabdevteam/core 0.342.0 → 0.344.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/components.cjs.js +40 -40
  3. package/dist/hooks.cjs.js +5 -3
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +188 -188
  6. package/dist/ihabdevteam-core.d.ts +2 -2
  7. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +4 -3
  8. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +14 -1
  9. package/dist/packages/core/src/hooks/useCacheManagement.js +15 -5
  10. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +16 -4
  11. package/dist/packages/core/src/hooks/useEditableList.js +11 -2
  12. package/dist/packages/core/src/hooks/useFocusTrap.js +11 -1
  13. package/dist/packages/core/src/hooks/useIcon.js +7 -1
  14. package/dist/packages/core/src/hooks/useInlineSvg.js +47 -18
  15. package/dist/packages/core/src/hooks/useListControls.js +11 -7
  16. package/dist/packages/core/src/hooks/usePasswordChange.js +15 -1
  17. package/dist/packages/core/src/hooks/useSoundCompassExam.js +5 -3
  18. package/dist/packages/core/src/hooks/useTablePaging.js +10 -5
  19. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +8 -4
  20. package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +5 -4
  21. package/dist/packages/core/src/hooks/useVisitAllPanel.js +11 -3
  22. package/dist/packages/core/src/hooks/useWordImage.js +15 -7
  23. package/dist/packages/core/src/hooks/useWordMeanings.js +36 -9
  24. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -5
  25. package/dist/packages/core/src/utils/getTrainingData.js +9 -2
  26. package/dist/packages/core/src/utils/imageConfig.js +52 -17
  27. package/dist/packages/core/src/utils/prefetchChunk.js +10 -3
  28. package/dist/packages/core/src/utils/speak.js +51 -27
  29. package/dist/packages/core/src/utils/ttsConfig.js +10 -3
  30. package/dist/packages/core/src/utils/wordPairIndex.js +16 -1
  31. package/dist/packages/core/src/version.d.ts +1 -1
  32. package/dist/packages/core/src/version.js +1 -1
  33. package/dist/utils.cjs.js +4 -4
  34. package/package.json +1 -1
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
1754
1754
  *
1755
1755
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1756
1756
  */
1757
- export declare const CORE_VERSION = "0.342.0";
1757
+ export declare const CORE_VERSION = "0.344.0";
1758
1758
 
1759
1759
  /** @public */
1760
1760
  declare const coreDefault: Record<string, unknown>;
@@ -10357,7 +10357,7 @@ export declare function useVisitAllPanel(mergedVisits: MergedVisit[]): {
10357
10357
  vapSearch: string;
10358
10358
  setVapSearch: Dispatch<SetStateAction<string>>;
10359
10359
  vapPageSize: string;
10360
- setVapPageSize: Dispatch<SetStateAction<string>>;
10360
+ setVapPageSize: (value: SetStateAction<string>) => void;
10361
10361
  /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
10362
10362
  vapPageSizeOptions: {
10363
10363
  label: string;
@@ -162,10 +162,11 @@ export default function WaveForm(props) {
162
162
  useEffect(() => {
163
163
  let cancelled = false;
164
164
  setNoiseFailed(false);
165
- if (!noiseSrc || !barsTotal) {
166
- setNoisePeaks(null);
165
+ // 파일·시간축이 바뀌면 앞 소음의 파형을 새 계산의 결과처럼 보여 주지 않는다.
166
+ // 문장 파형은 hasSyncedOnce 래치가 유지하므로 소음 재계산 중에도 계속 보인다.
167
+ setNoisePeaks(null);
168
+ if (!noiseSrc || !barsTotal)
167
169
  return;
168
- }
169
170
  const windowSec = totalDuration > 0 ? totalDuration * (noiseSpeed || 1) : undefined;
170
171
  /* 소음은 루프되는 바탕이라 **앞머리 무음을 건너뛴다**((82)) — 그 무음은 맨 처음 한 번만
171
172
  지나가는 인코더 자국인데, 창이 짧으면 앞칸 예닐곱을 먹어 "앞쪽 소음이 약하다" 로 보였다. */
@@ -197,7 +197,20 @@ function TrainingPagePopupInner(props) {
197
197
  reportAppliedNoiseSettings(applied);
198
198
  }
199
199
  };
200
- void loadNoiseSettings().then(apply);
200
+ void loadNoiseSettings().then(saved => {
201
+ if (cancelled) {
202
+ // 초기 로드를 기다리던 사이 창이 닫혀도, 그 전에 사용자가 바꾼 값은
203
+ // 저장값 위에 병합해 남긴다. 로드 전 기본 enabled:false 를 그대로 저장하지 않는다.
204
+ const pending = pendingNoiseRef.current;
205
+ pendingNoiseRef.current = null;
206
+ if (pending && !noiseOverrideRef.current) {
207
+ const loaded = saved ? mergeNoiseSettings({ ...DEFAULT_NOISE_SETTINGS, enabled: false }, saved) : { ...DEFAULT_NOISE_SETTINGS };
208
+ saveNoiseSettings(mergeNoiseSettings(loaded, pending));
209
+ }
210
+ return;
211
+ }
212
+ apply(saved);
213
+ });
201
214
  return () => { cancelled = true; };
202
215
  }, []);
203
216
  // 오디오 레벨 미터용 analyser (오디오 플레이어가 재생 시 등록)
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useState, useCallback, useEffect } from 'react';
2
+ import { useState, useCallback, useEffect, useRef } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { clearPairIndexCache } from '../utils/wordPairIndex';
5
5
  import { clearImageExistenceCache } from '../utils/imageConfig';
@@ -62,19 +62,29 @@ export function useCacheManagement(onToast) {
62
62
  const [clearingId, setClearingId] = useState(null);
63
63
  const [confirmCacheId, setConfirmCacheId] = useState(null);
64
64
  const [storageEstimate, setStorageEstimate] = useState(null);
65
+ const estimateVersion = useRef(0);
65
66
  const refreshCacheSizes = useCallback(() => {
67
+ const version = ++estimateVersion.current;
66
68
  const sizes = {};
67
69
  CACHE_GROUPS.forEach(g => { sizes[g.id] = lsBytes([...g.keys]); });
68
70
  setCacheSizes(sizes);
69
71
  if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
70
- navigator.storage.estimate().then(e => {
71
- setStorageEstimate({ usage: e.usage ?? 0, quota: e.quota ?? 0 });
72
- }).catch(() => { });
72
+ try {
73
+ void navigator.storage.estimate().then(e => {
74
+ if (estimateVersion.current === version) {
75
+ setStorageEstimate({ usage: e.usage ?? 0, quota: e.quota ?? 0 });
76
+ }
77
+ }).catch(() => { });
78
+ }
79
+ catch { /* 브라우저가 추정을 거부해도 로컬 캐시 용량은 표시한다 */ }
73
80
  }
74
81
  }, []);
75
82
  // refreshCacheSizes는 setSizes 등을 동기 호출하지만, 마운트 시 캐시 용량을 반영하는 의도된 fetch-kickoff이다
76
83
  // eslint-disable-next-line react-hooks/set-state-in-effect
77
- useEffect(() => { refreshCacheSizes(); }, [refreshCacheSizes]);
84
+ useEffect(() => {
85
+ refreshCacheSizes();
86
+ return () => { estimateVersion.current += 1; };
87
+ }, [refreshCacheSizes]);
78
88
  const handleClearCache = useCallback(async (id) => {
79
89
  setClearingId(id);
80
90
  try {
@@ -35,6 +35,9 @@ export function useDailyTrainingSelection(userId) {
35
35
  /* 오늘(KST) — 자정을 넘기면 바뀌어 아래 선택을 **새 날짜 시드로** 다시 계산하게 한다. 전에는 userId 만 봐서 화면을 열어 둔 채
36
36
  자정을 넘기면 어제 시드의 세트가 '오늘' 로 보이는데 완료 기록에는 새 날짜가 찍혔다. */
37
37
  const [today, setToday] = useState(() => getTodayKst());
38
+ const ownerKey = JSON.stringify([userId ?? '', today]);
39
+ const selectionOwnerRef = useRef(null);
40
+ const statusOwnerRef = useRef(ownerKey);
38
41
  useEffect(() => {
39
42
  const msUntilMidnight = () => {
40
43
  const now = Date.now();
@@ -46,6 +49,11 @@ export function useDailyTrainingSelection(userId) {
46
49
  return () => clearTimeout(t);
47
50
  }, []);
48
51
  useEffect(() => {
52
+ // 사용자나 날짜가 바뀌면 이전 선택을 새 선택이 준비될 때까지 노출하지 않는다.
53
+ statusOwnerRef.current = ownerKey;
54
+ selectionOwnerRef.current = null;
55
+ setLoaded(false);
56
+ setHasData(false);
49
57
  let cancelled = false;
50
58
  let left = false;
51
59
  let applied = false;
@@ -69,6 +77,7 @@ export function useDailyTrainingSelection(userId) {
69
77
  if (left)
70
78
  return;
71
79
  applied = true;
80
+ selectionOwnerRef.current = ownerKey;
72
81
  todayRef.current = sel.today;
73
82
  yesterdayRef.current = sel.yesterday;
74
83
  setLoaded(true);
@@ -133,8 +142,8 @@ export function useDailyTrainingSelection(userId) {
133
142
  window.addEventListener('speechear.data.updated', onData);
134
143
  }
135
144
  catch { }
136
- tryFill();
137
145
  timer = setInterval(tryFill, 250);
146
+ tryFill();
138
147
  return () => {
139
148
  cancelled = true;
140
149
  left = true;
@@ -148,17 +157,20 @@ export function useDailyTrainingSelection(userId) {
148
157
  // eslint-disable-next-line react-hooks/exhaustive-deps
149
158
  }, [userId, today]);
150
159
  const getTrainingPerType = useCallback((typeKey) => {
160
+ if (selectionOwnerRef.current !== ownerKey)
161
+ return undefined;
151
162
  const src = showYesterday ? yesterdayRef.current : todayRef.current;
152
163
  const fallback = showYesterday ? todayRef.current : yesterdayRef.current;
153
164
  const pick = src?.[typeKey]?.length
154
165
  ? src[typeKey]
155
166
  : (fallback?.[typeKey]?.length ? fallback[typeKey] : undefined);
156
167
  return pick;
157
- }, [showYesterday, revision]);
168
+ }, [showYesterday, revision, ownerKey]);
158
169
  return {
159
- loaded,
170
+ // effect가 실행되기 전 첫 커밋에도 이전 사용자의 완료 상태를 보이지 않는다.
171
+ loaded: statusOwnerRef.current === ownerKey && loaded,
160
172
  /** 뽑을 것이 실제로 있었나. loaded 만으로는 "포기했다" 와 가를 수 없다. */
161
- hasData,
173
+ hasData: statusOwnerRef.current === ownerKey && hasData,
162
174
  showYesterday, setShowYesterday, getTrainingPerType,
163
175
  };
164
176
  }
@@ -60,8 +60,17 @@ export function useEditableList(emptyItem, initialList = []) {
60
60
  return;
61
61
  prevInitialRef.current = initialList;
62
62
  setList(initialList);
63
- // 고른 항목도 새 목록의 같은 항목(id)으로 옮긴다 — 옛 참조로 남으면 지우기·수정 대상 찾기가 헛돈다
64
- setSelected(prev => (prev == null ? prev : initialList.find(v => isSameEditableItem(v, prev)) ?? prev));
63
+ // 서버 id가 있는 항목은 새 목록에서 사라졌다면 상세도 닫는다. id 없는 로컬 항목은
64
+ // 아직 서버 목록에 반영되지 않았을 수 있으므로 선택을 유지한다.
65
+ setSelected(prev => {
66
+ if (prev == null)
67
+ return prev;
68
+ const current = initialList.find(v => isSameEditableItem(v, prev));
69
+ if (current != null)
70
+ return current;
71
+ const id = prev.id;
72
+ return id != null && id !== '' ? null : prev;
73
+ });
65
74
  }, [initialList, isEditing]);
66
75
  /* 편집 중이 아닐 때는 편집 폼을 고른 항목에 맞춘다 — 취소하거나(서버 거절로) 되돌려도 폼이 저장 안 된 값을 들고 있어,
67
76
  상세 화면에 거절된 값이 보이고 다시 '수정하기' 를 누르면 그 값에서 시작했다. */
@@ -1,5 +1,15 @@
1
1
  'use client';
2
2
  import { useEffect } from 'react';
3
+ function isTabbable(el) {
4
+ if (el.closest('[aria-hidden="true"], [hidden], [inert]'))
5
+ return false;
6
+ for (let node = el; node; node = node.parentElement) {
7
+ const style = window.getComputedStyle(node);
8
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse')
9
+ return false;
10
+ }
11
+ return true;
12
+ }
3
13
  /**
4
14
  * 다이얼로그 안에서 Tab 이 빠져나가지 못하게 가둔다 — WAI-ARIA 다이얼로그 패턴.
5
15
  *
@@ -29,7 +39,7 @@ export function useFocusTrap(panelRef, enabled = true) {
29
39
  /* `aria-hidden` 아래는 뺀다 — 낭독기에게 없는 것으로 해 놓고 Tab 으로는
30
40
  들르게 하면 두 길이 어긋난다. TrainingPagePopup 의 사본만 이 거르개를
31
41
  갖고 있었고, 그쪽을 이 훅으로 옮기면서 여기로 가져왔다. */
32
- .filter((el) => !el.closest('[aria-hidden="true"]'));
42
+ .filter(isTabbable);
33
43
  /* 안에 갈 곳이 없으면 Tab 자체를 막는다 — 안 막으면 배경으로 나간다 */
34
44
  if (!focusables.length) {
35
45
  e.preventDefault();
@@ -37,6 +37,7 @@ function sanitizeSvg(raw) {
37
37
  }
38
38
  /** @public */
39
39
  export default function useIcon(icon) {
40
+ const [sourceIcon, setSourceIcon] = useState(null);
40
41
  const [iconToUse, setIconToUse] = useState(null);
41
42
  const [mode, setMode] = useState(null);
42
43
  const [svgText, setSvgText] = useState(null);
@@ -45,6 +46,7 @@ export default function useIcon(icon) {
45
46
  /* eslint-disable react-hooks/set-state-in-effect */
46
47
  useEffect(() => {
47
48
  if (!icon || typeof icon !== 'string' || !icon.endsWith('.svg')) {
49
+ setSourceIcon(null);
48
50
  setIconToUse(null);
49
51
  setMode(null);
50
52
  setSvgText(null);
@@ -69,6 +71,7 @@ export default function useIcon(icon) {
69
71
  // 다른 파일로 바뀌면 앞 아이콘의 판정·SVG 를 걷는다 — 안 걷으면 새 파일을 받는 동안 앞 아이콘이 그대로 그려졌다
70
72
  setMode(null);
71
73
  setSvgText(null);
74
+ setSourceIcon(icon);
72
75
  setIconToUse(normalized);
73
76
  }, [icon]);
74
77
  /* eslint-enable react-hooks/set-state-in-effect */
@@ -147,5 +150,8 @@ export default function useIcon(icon) {
147
150
  setBlobUrl(null);
148
151
  };
149
152
  }, [mode, iconToUse]);
150
- return { iconToUse, mode, svgText, blobUrl };
153
+ // prop 변경 직후 첫 커밋은 위 effect보다 먼저 일어난다. 그 프레임에는 옛 파일의 상태를 숨긴다.
154
+ return sourceIcon === icon
155
+ ? { iconToUse, mode, svgText, blobUrl }
156
+ : { iconToUse: null, mode: null, svgText: null, blobUrl: null };
151
157
  }
@@ -1,5 +1,6 @@
1
1
  'use client';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useState, useSyncExternalStore } from 'react';
3
+ import DOMPurify from 'dompurify';
3
4
  /**
4
5
  * **SVG 의 글자를 얻는다 — 속에 넣어 칠하려는 쪽이 쓴다.**
5
6
  *
@@ -24,13 +25,19 @@ import { useEffect, useState } from 'react';
24
25
  const cache = new Map();
25
26
  /** 받는 중인 약속 — 같은 주소를 동시에 둘이 부르면 하나만 간다 */
26
27
  const inFlight = new Map();
28
+ const subscribeBrowser = () => () => { };
29
+ const browserSnapshot = () => true;
30
+ const serverSnapshot = () => false;
27
31
  /** `data:image/svg+xml,…` 에서 글자를 꺼낸다. 그 꼴이 아니면 `null`. */
28
32
  export function svgTextFromDataUrl(src) {
29
33
  const m = /^data:image\/svg\+xml(;base64)?,([\s\S]*)$/.exec(src);
30
34
  if (!m)
31
35
  return null;
32
36
  try {
33
- return m[1] ? atob(m[2]) : decodeURIComponent(m[2]);
37
+ if (!m[1])
38
+ return decodeURIComponent(m[2]);
39
+ const bytes = Uint8Array.from(atob(m[2]), ch => ch.charCodeAt(0));
40
+ return new TextDecoder('utf-8').decode(bytes);
34
41
  }
35
42
  catch {
36
43
  return null;
@@ -44,13 +51,31 @@ export function svgTextFromDataUrl(src) {
44
51
  * 이미 막아 두었다(`wordTablePrint`).
45
52
  */
46
53
  export function stripActiveSvg(svg) {
47
- return svg
48
- .replace(/<script[\s\S]*?<\/script\s*>/gi, '')
49
- .replace(/<script[^>]*\/>/gi, '')
50
- /* `on*` 꼬리표 — 따옴표가 있든 없든 */
51
- .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
52
- /* `href="javascript:…"` 도 걷는다 */
53
- .replace(/\s(xlink:)?href\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, '');
54
+ if (typeof window === 'undefined')
55
+ return '';
56
+ // 로고 안의 그라데이션 참조(#id)는 두고 외부 자원과 실행 가능한 주소는 걷는다.
57
+ // 문자열 치환은 따옴표 없는 href, 엔티티, foreignObject 같은 HTML 삽입을 놓친다.
58
+ const onAttribute = (_node, data) => {
59
+ const name = data.attrName.toLowerCase();
60
+ const value = data.attrValue.trim();
61
+ if ((name === 'href' || name === 'xlink:href') && !value.startsWith('#'))
62
+ data.keepAttr = false;
63
+ if (name === 'style' || (value.includes('\\') && value.includes('(')))
64
+ data.keepAttr = false;
65
+ const withoutLocalFragments = value.replace(/url\s*\(\s*(['"]?)#[^)\s'"]+\1\s*\)/gi, '');
66
+ if (/url\s*\(/i.test(withoutLocalFragments))
67
+ data.keepAttr = false;
68
+ };
69
+ DOMPurify.addHook('uponSanitizeAttribute', onAttribute);
70
+ try {
71
+ return DOMPurify.sanitize(svg, {
72
+ USE_PROFILES: { svg: true, svgFilters: true },
73
+ FORBID_TAGS: ['script', 'style', 'foreignObject'],
74
+ });
75
+ }
76
+ finally {
77
+ DOMPurify.removeHook('uponSanitizeAttribute', onAttribute);
78
+ }
54
79
  }
55
80
  /**
56
81
  * **되부르는 id 에 제 이름표를 붙인다** — 속에 둘 이상 넣었을 때 엉키지 않게.
@@ -68,8 +93,8 @@ export function stripActiveSvg(svg) {
68
93
  */
69
94
  export function namespaceSvgIds(svg, prefix) {
70
95
  const referenced = new Set();
71
- for (const m of svg.matchAll(/url\(#([^)\s"']+)\)/g))
72
- referenced.add(m[1]);
96
+ for (const m of svg.matchAll(/url\(\s*(['"]?)#([^)\s"']+)\1\s*\)/g))
97
+ referenced.add(m[2]);
73
98
  for (const m of svg.matchAll(/(?:xlink:)?href="#([^"]+)"/g))
74
99
  referenced.add(m[1]);
75
100
  /* 이 파일이 **실제로 가진** id 만 — 모르는 이름은 건드리지 않는다 */
@@ -83,7 +108,7 @@ export function namespaceSvgIds(svg, prefix) {
83
108
  const to = `${prefix}-${id}`;
84
109
  out = out
85
110
  .replace(new RegExp(`id="${q}"`, 'g'), `id="${to}"`)
86
- .replace(new RegExp(`url\\(#${q}\\)`, 'g'), `url(#${to})`)
111
+ .replace(new RegExp(`url\\(\\s*(['"]?)#${q}\\1\\s*\\)`, 'g'), (_match, quote) => `url(${quote}#${to}${quote})`)
87
112
  .replace(new RegExp(`((?:xlink:)?href)="#${q}"`, 'g'), `$1="#${to}"`);
88
113
  }
89
114
  return out;
@@ -111,23 +136,27 @@ function load(src) {
111
136
  * @public
112
137
  */
113
138
  export function useInlineSvg(src, enabled = true) {
114
- const immediate = enabled && src ? (svgTextFromDataUrl(src) ?? cache.get(src) ?? null) : null;
115
- const [svg, setSvg] = useState(immediate);
139
+ // 서버는 DOMPurify를 실행할 DOM이 없다. 수화 중 첫 그림도 서버와 같은 <img>로 두고
140
+ // 브라우저가 준비된 뒤에만 인라인 SVG로 바꿔 마크업 불일치를 막는다.
141
+ const browserReady = useSyncExternalStore(subscribeBrowser, browserSnapshot, serverSnapshot);
142
+ const immediate = browserReady && enabled && src ? (svgTextFromDataUrl(src) ?? cache.get(src) ?? null) : null;
143
+ const [loaded, setLoaded] = useState({ src, enabled, svg: immediate });
116
144
  useEffect(() => {
117
145
  if (!enabled || !src) {
118
- setSvg(null);
146
+ setLoaded({ src, enabled, svg: null });
119
147
  return;
120
148
  }
121
149
  const now = svgTextFromDataUrl(src) ?? cache.get(src) ?? null;
122
150
  if (now !== null) {
123
- setSvg(now);
151
+ setLoaded({ src, enabled, svg: now });
124
152
  return;
125
153
  }
126
154
  let alive = true;
127
155
  void load(src).then((t) => { if (alive)
128
- setSvg(t); });
156
+ setLoaded({ src, enabled, svg: t }); });
129
157
  /* 늦게 온 답이 **다음 주소의 자리**를 덮지 않게 — 같은 자리에서 `src` 가 바뀔 수 있다 */
130
158
  return () => { alive = false; };
131
159
  }, [src, enabled]);
132
- return svg;
160
+ // 주소나 사용 여부가 바뀐 첫 렌더에는 이전 SVG를 내보내지 않는다.
161
+ return loaded.src === src && loaded.enabled === enabled ? loaded.svg : immediate;
133
162
  }
@@ -40,13 +40,17 @@ export function useListControls(opts = {}) {
40
40
  const [query, setQuery] = useState('');
41
41
  const [pageSize, setPageSize] = useState(defaultPageSize);
42
42
  const [page, setPage] = useState(1);
43
- /* 못 읽히는 쪽크기('' · 'all' · '0')는 기본값으로 — 그 셈이 화면마다 한 벌씩 있었다.
44
- 기본값 자체가 못 읽히면 25 다. 목록이 통째로 사라지는 값(0·NaN)을 여기서 닫는다. */
45
- const defaultSize = Number(defaultPageSize) || 25;
46
- const perPage = Number(pageSize) || defaultSize;
43
+ /* 못 읽히거나 유효하지 않은 쪽크기('' · 'all' · '0' · 음수 · Infinity)는 기본값으로.
44
+ 기본값 자체가 잘못됐으면 25다. */
45
+ const validSize = (value) => {
46
+ const n = Number(value);
47
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
48
+ };
49
+ const defaultSize = validSize(defaultPageSize) ?? 25;
50
+ const perPage = validSize(pageSize) ?? defaultSize;
47
51
  /* **검색어와 쪽크기를 여기서 넣는다** — 부르는 쪽이 적으면 빠뜨릴 수 있고, 빠뜨려도 안 빨개진다.
48
- 이어 붙이는 것은 `usePageResetOnChange` 가 글 하나를 받기 때문이고, 값이 글·수·참거짓뿐이라
49
- `[object Object]` 로 뭉개질 일이 없다(타입이 막는다). */
50
- usePageResetOnChange([query, pageSize, ...(resetOn ?? [])].map(String).join('|'), setPage);
52
+ 배열 직렬화로 각 값의 경계를 보존한다. `|`로 잇기만 하면 값 자체에 `|`가 있을 때
53
+ 서로 다른 필터 조합이 같은 열쇠가 되어 페이지가 돌아가지 않는다. */
54
+ usePageResetOnChange(JSON.stringify([query, pageSize, ...(resetOn ?? [])]), setPage);
51
55
  return { query, setQuery, pageSize, setPageSize, perPage, page, setPage };
52
56
  }
@@ -18,6 +18,7 @@ export function usePasswordChange(onChangePassword) {
18
18
  /* 처리 중에는 다시 받지 않는다 — 두 번 누르면 변경이 두 번 나가, 먼저 온 성공 뒤에 늦게 온 "현재 비밀번호 틀림" 이
19
19
  남아 바뀌었는데 오류 문구가 떴다(말귀 계정 화면 단추는 로딩 중에도 눌린다) */
20
20
  const busyRef = useRef(false);
21
+ const generationRef = useRef(0);
21
22
  useEffect(() => {
22
23
  return () => {
23
24
  if (clearTimerRef.current !== null)
@@ -25,12 +26,22 @@ export function usePasswordChange(onChangePassword) {
25
26
  };
26
27
  }, []);
27
28
  const reset = useCallback(() => {
29
+ // 닫힌 폼에서 시작한 요청의 늦은 결과는 새로 연 폼에 반영하지 않는다.
30
+ generationRef.current++;
31
+ if (clearTimerRef.current !== null) {
32
+ clearTimeout(clearTimerRef.current);
33
+ clearTimerRef.current = null;
34
+ }
28
35
  setPw({ current: '', next: '', confirm: '' });
29
36
  setPwMsg(null);
30
37
  }, []);
31
38
  const handleChangePw = useCallback(async () => {
32
39
  if (busyRef.current)
33
40
  return;
41
+ if (clearTimerRef.current !== null) {
42
+ clearTimeout(clearTimerRef.current);
43
+ clearTimerRef.current = null;
44
+ }
34
45
  if (!pw.current) {
35
46
  setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
36
47
  return;
@@ -47,6 +58,7 @@ export function usePasswordChange(onChangePassword) {
47
58
  }
48
59
  setPwLoading(true);
49
60
  busyRef.current = true;
61
+ const generation = generationRef.current;
50
62
  // 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
51
63
  // reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
52
64
  let err;
@@ -60,6 +72,8 @@ export function usePasswordChange(onChangePassword) {
60
72
  busyRef.current = false;
61
73
  setPwLoading(false);
62
74
  }
75
+ if (generation !== generationRef.current)
76
+ return;
63
77
  if (err) {
64
78
  setPwMsg({ type: 'error', text: err });
65
79
  return;
@@ -68,7 +82,7 @@ export function usePasswordChange(onChangePassword) {
68
82
  setPw({ current: '', next: '', confirm: '' });
69
83
  if (clearTimerRef.current !== null)
70
84
  clearTimeout(clearTimerRef.current);
71
- clearTimerRef.current = setTimeout(() => setPwMsg(null), 1500);
85
+ clearTimerRef.current = setTimeout(() => { clearTimerRef.current = null; setPwMsg(null); }, 1500);
72
86
  }, [pw, onChangePassword, t]);
73
87
  return { pw, setPw, pwMsg, setPwMsg, pwLoading, handleChangePw, reset };
74
88
  }
@@ -85,9 +85,11 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
85
85
  }, [setScoreTracked]);
86
86
  // autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
87
87
  /* eslint-disable react-hooks/set-state-in-effect */
88
- /* 배치가 바뀌었는지는 **스피커 id 들**로 본다 — 개수만 보면 수가 같은 다른 배치(HORIZON_7 ↔ PRESET_27 가로)로 바꿔도
89
- 초기화되지 않아 옛 문제 줄이 남고 정답 id 가 새 배치에 없어 무엇을 골라도 오답이었다. */
90
- const layoutKey = speakers.map((sp) => sp.id).join('|');
88
+ /* 스피커의 위치와 표시 정보까지 비교한다. id 가 같아도 배치가 움직이면 큐에 든 옛
89
+ SpeakerPos 로 재생되며, 구분자로 이어 붙인 id 는 서로 다른 목록이 충돌할 수 있다. */
90
+ const layoutKey = JSON.stringify(speakers.map(sp => [
91
+ sp.id, sp.label, sp.angleDeg, sp.deg, sp.row, sp.shortcut,
92
+ ]));
91
93
  useEffect(() => {
92
94
  if (autoStart)
93
95
  start();
@@ -43,14 +43,19 @@ export function useTablePaging(rows, resetOn, opts) {
43
43
  const pageSizeOptions = useMemo(() => VAP_PAGE_SIZE_OPTIONS.map(o => ({ ...o, label: t('visits.showN', { n: o.value }) })), [t]);
44
44
  /* 개수를 바꾸면 첫 쪽으로 — 25개씩 보다가 100개씩으로 바꿨는데 3쪽에 남아 있으면
45
45
  대부분의 경우 빈 표가 된다 */
46
- const setPageSize = useCallback((v) => { setPageSizeRaw(v); setPage(1); }, []);
46
+ const setPageSize = useCallback((v) => {
47
+ if (!VAP_PAGE_SIZE_OPTIONS.some(o => o.value === v))
48
+ return;
49
+ setPageSizeRaw(v);
50
+ setPage(1);
51
+ }, []);
47
52
  const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
48
53
  /* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
49
54
  5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
50
- // 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
51
- if (page > total || page < 1 || !Number.isFinite(page))
52
- setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
53
- const clampedPage = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
55
+ // 0·음수·소수·비유한 값은 유효한 정수 쪽으로 맞춘다. 소수는 slice 경계까지 어긋난다.
56
+ const clampedPage = Math.min(Math.max(1, Number.isFinite(page) ? Math.floor(page) : 1), total);
57
+ if (page !== clampedPage)
58
+ setPage(clampedPage);
54
59
  const pageRows = useMemo(() => rows.slice((clampedPage - 1) * Number(pageSize), clampedPage * Number(pageSize)), [rows, clampedPage, pageSize]);
55
60
  return {
56
61
  pageRows,
@@ -78,8 +78,11 @@ export default function useTrainingSessionLog(trainingType) {
78
78
  } };
79
79
  if (isVisible())
80
80
  openSpan();
81
- /* 아직 안 보낸 만큼을 한 줄로. 끝 시각은 처음 보인 때 + 모은 시간, 시작 시각은 앞 줄이
82
- 끝난 자리 — 줄끼리 겹치지 않으니 합산해도 이중으로 세지 않는다.
81
+ /* 아직 안 보낸 만큼을 한 줄로. 종료 시각은 실제로 떠난 때이고, 시작 시각은 그때에서
82
+ 아직 안 보낸 가시 시간만큼 거슬러 올라간다. 최초 시각에 누계를 더하면 오래 숨긴 뒤
83
+ 돌아온 구간까지 숨기기 전(자정을 넘겼다면 전날) 시각으로 기록된다.
84
+ 한 줄로 합쳐 보내는 동안 숨김이 끼었다면 그 줄 안의 정확한 구간 경계는 표현할 수 없지만,
85
+ 길이와 종료 시각은 맞고 앞서 보낸 줄과도 겹치지 않는다.
83
86
  전달은 등록된 로거(logTrainingTime)로 간다. 코어는 보낼 주소를 모르므로
84
87
  navigator.sendBeacon 을 여기서 직접 쓸 수 없다 — 앱이 등록하는 로거의 몫이다. */
85
88
  const send = () => {
@@ -95,12 +98,13 @@ export default function useTrainingSessionLog(trainingType) {
95
98
  // 훈련이라, 돌아와 더 한 시간은 짧아도 버리지 않는다.
96
99
  if (sentMs === 0 && accumulatedMs < 5000)
97
100
  return;
98
- const startMs = firstVisibleAt + sentMs;
101
+ const endedAt = Date.now();
102
+ const startMs = endedAt - pendingMs;
99
103
  sentMs = accumulatedMs;
100
104
  logTrainingTime({
101
105
  trainingType,
102
106
  startedAt: new Date(startMs).toISOString(),
103
- endedAt: new Date(firstVisibleAt + accumulatedMs).toISOString(),
107
+ endedAt: new Date(endedAt).toISOString(),
104
108
  });
105
109
  };
106
110
  // 숨으면 보낸다(앱이 그대로 죽을 수 있다). 다시 보이면 이어서 모은다.
@@ -1,3 +1,4 @@
1
+ import { type SetStateAction } from 'react';
1
2
  import type { VisitItem } from './useVisitHistory';
2
3
  export type VisitAllStatus = 'scheduled' | 'visited' | 'noShow';
3
4
  export type MergedVisit = {
@@ -19,18 +20,18 @@ export declare const VAP_PAGE_SIZE_OPTIONS: {
19
20
  */
20
21
  export declare function useVisitAllPanel(mergedVisits: MergedVisit[]): {
21
22
  vapSearch: string;
22
- setVapSearch: import("react").Dispatch<import("react").SetStateAction<string>>;
23
+ setVapSearch: import("react").Dispatch<SetStateAction<string>>;
23
24
  vapPageSize: string;
24
- setVapPageSize: import("react").Dispatch<import("react").SetStateAction<string>>;
25
+ setVapPageSize: (value: SetStateAction<string>) => void;
25
26
  /** 번역된 개수 보기 옵션 — `VAP_PAGE_SIZE_OPTIONS` 와 값은 같고 라벨만 지금 언어로 */
26
27
  vapPageSizeOptions: {
27
28
  label: string;
28
29
  value: string;
29
30
  }[];
30
31
  vapStatus: string;
31
- setVapStatus: import("react").Dispatch<import("react").SetStateAction<string>>;
32
+ setVapStatus: import("react").Dispatch<SetStateAction<string>>;
32
33
  vapPage: number;
33
- setVapPage: import("react").Dispatch<import("react").SetStateAction<number>>;
34
+ setVapPage: import("react").Dispatch<SetStateAction<number>>;
34
35
  vapFiltered: MergedVisit[];
35
36
  vapTotal: number;
36
37
  vapPageItems: MergedVisit[];
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useMemo, useState } from 'react';
2
+ import { useCallback, useMemo, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  /**
5
5
  * 개수 보기 옵션의 값 목록. 라벨은 한국어 고정이다 — 화면에는 훅이 돌려주는
@@ -19,9 +19,15 @@ export const VAP_PAGE_SIZE_OPTIONS = [
19
19
  export function useVisitAllPanel(mergedVisits) {
20
20
  const { t } = useTranslation();
21
21
  const [vapSearch, setVapSearch] = useState('');
22
- const [vapPageSize, setVapPageSize] = useState('25');
22
+ const [vapPageSize, setVapPageSizeRaw] = useState('25');
23
23
  const [vapStatus, setVapStatus] = useState('all');
24
24
  const [vapPage, setVapPage] = useState(1);
25
+ const setVapPageSize = useCallback((value) => {
26
+ setVapPageSizeRaw(prev => {
27
+ const next = typeof value === 'function' ? value(prev) : value;
28
+ return VAP_PAGE_SIZE_OPTIONS.some(option => option.value === next) ? next : prev;
29
+ });
30
+ }, []);
25
31
  const vapFiltered = useMemo(() => mergedVisits.filter(({ item: v, status }) => {
26
32
  if (vapSearch && !v.memo.includes(vapSearch))
27
33
  return false;
@@ -38,7 +44,9 @@ export function useVisitAllPanel(mergedVisits) {
38
44
  // 상태는 손대지 않고 돌려주는 값만 조인다. 상태는 "어디로 가려 했나"(의도)이고
39
45
  // 돌려주는 값은 "지금 어디인가"(사실)다. 그래서 setVapPage 의 뜻이 안 바뀌어
40
46
  // 소비자를 깨뜨리지 않고, 줄었다 늘어나면 원래 보던 쪽으로 돌아온다.
41
- const vapPageClamped = Math.min(vapPage, vapTotal);
47
+ const vapPageClamped = Number.isFinite(vapPage)
48
+ ? Math.min(Math.max(1, Math.floor(vapPage)), vapTotal)
49
+ : 1;
42
50
  const vapPageItems = useMemo(() => {
43
51
  return vapFiltered.slice((vapPageClamped - 1) * Number(vapPageSize), vapPageClamped * Number(vapPageSize));
44
52
  }, [vapFiltered, vapPageClamped, vapPageSize]);
@@ -13,16 +13,24 @@ import { useCallback, useEffect, useRef, useState } from 'react';
13
13
  */
14
14
  export function useWordImage(imageUrl) {
15
15
  const imgRef = useRef(null);
16
- const [loading, setLoading] = useState(!!imageUrl);
17
- const [failed, setFailed] = useState(false);
16
+ const currentUrlRef = useRef(imageUrl);
17
+ currentUrlRef.current = imageUrl;
18
+ const [state, setState] = useState({ url: imageUrl, loading: !!imageUrl, failed: false });
19
+ // 주소가 바뀐 첫 렌더에서 이전 이미지의 실패·로딩 상태를 재사용하지 않는다.
20
+ const current = state.url === imageUrl ? state : { loading: !!imageUrl, failed: false };
18
21
  /* eslint-disable react-hooks/set-state-in-effect */
19
22
  useEffect(() => {
20
- setFailed(false);
21
23
  const img = imgRef.current;
22
- setLoading(!!imageUrl && !(img?.complete && img.naturalWidth > 0));
24
+ setState({ url: imageUrl, failed: false, loading: !!imageUrl && !(img?.complete && img.naturalWidth > 0) });
23
25
  }, [imageUrl]);
24
26
  /* eslint-enable react-hooks/set-state-in-effect */
25
- const onLoad = useCallback(() => setLoading(false), []);
26
- const onError = useCallback(() => { setLoading(false); setFailed(true); }, []);
27
- return { imgRef, loading, failed, hasImage: !!imageUrl && !failed, onLoad, onError };
27
+ const onLoad = useCallback(() => {
28
+ if (currentUrlRef.current === imageUrl)
29
+ setState({ url: imageUrl, loading: false, failed: false });
30
+ }, [imageUrl]);
31
+ const onError = useCallback(() => {
32
+ if (currentUrlRef.current === imageUrl)
33
+ setState({ url: imageUrl, loading: false, failed: true });
34
+ }, [imageUrl]);
35
+ return { imgRef, loading: current.loading, failed: current.failed, hasImage: !!imageUrl && !current.failed, onLoad, onError };
28
36
  }