@uniai-fe/uds-templates 0.9.1 → 0.10.1
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/package.json +11 -11
- package/src/auth/common/find/markup/CodeStep.tsx +9 -7
- package/src/auth/common/find/markup/InfoStep.tsx +4 -6
- package/src/auth/find-password/markup/StepResetPassword.tsx +9 -2
- package/src/auth/login/markup/FormField.tsx +16 -12
- package/src/cctv/hooks/streamScheduler.ts +11 -16
- package/src/cctv/hooks/useRtcStream.ts +42 -68
- package/src/cctv/types/carousel.ts +3 -3
- package/src/page-frame/mobile/components/page/Frame.tsx +4 -8
- package/src/service-inquiry/components/Form.tsx +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "UNIAI Design System; UI Templates Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@tanstack/react-query": ">=5 <6",
|
|
39
39
|
"@uniai-fe/uds-foundation": "^0.4.8 || ^0.5.0",
|
|
40
|
-
"@uniai-fe/uds-primitives": "^0.8.0 || ^0.9.0",
|
|
40
|
+
"@uniai-fe/uds-primitives": "^0.8.0 || ^0.9.0 || ^0.10.0",
|
|
41
41
|
"@uniai-fe/util-api": "^0.1.15 || ^0.2.0",
|
|
42
42
|
"@uniai-fe/util-functions": "^0.3.0 || ^0.4.0",
|
|
43
43
|
"@uniai-fe/util-jotai": "^0.1.9 || ^0.2.0 || ^0.3.0",
|
|
44
44
|
"@uniai-fe/util-next": "^0.3.0 || ^0.4.0 || ^0.5.0",
|
|
45
45
|
"@uniai-fe/util-rtc": "^0.1.4 || ^0.2.0",
|
|
46
46
|
"jotai": ">=2 <3",
|
|
47
|
-
"next": ">=
|
|
47
|
+
"next": ">=15.5.20 <17",
|
|
48
48
|
"react": ">=19 <20",
|
|
49
49
|
"react-dom": ">=19 <20",
|
|
50
50
|
"react-hook-form": ">=7 <8",
|
|
@@ -67,17 +67,17 @@
|
|
|
67
67
|
"react-hook-form": "^7.80.0",
|
|
68
68
|
"sass": "^1.101.0",
|
|
69
69
|
"typescript": "6.0.3",
|
|
70
|
-
"@uniai-fe/
|
|
71
|
-
"@uniai-fe/util-functions": "0.4.1",
|
|
72
|
-
"@uniai-fe/util-api": "0.2.1",
|
|
73
|
-
"@uniai-fe/uds-foundation": "0.5.0",
|
|
74
|
-
"@uniai-fe/react-hooks": "0.2.0",
|
|
70
|
+
"@uniai-fe/eslint-config": "0.4.0",
|
|
75
71
|
"@uniai-fe/next-devkit": "0.4.0",
|
|
76
|
-
"@uniai-fe/
|
|
72
|
+
"@uniai-fe/react-hooks": "0.3.0",
|
|
73
|
+
"@uniai-fe/tsconfig": "0.2.0",
|
|
74
|
+
"@uniai-fe/uds-foundation": "0.5.0",
|
|
75
|
+
"@uniai-fe/util-api": "0.2.1",
|
|
76
|
+
"@uniai-fe/util-functions": "0.4.1",
|
|
77
77
|
"@uniai-fe/util-jotai": "0.3.0",
|
|
78
78
|
"@uniai-fe/util-next": "0.5.0",
|
|
79
|
-
"@uniai-fe/
|
|
80
|
-
"@uniai-fe/
|
|
79
|
+
"@uniai-fe/util-rtc": "0.2.0",
|
|
80
|
+
"@uniai-fe/uds-primitives": "0.10.0"
|
|
81
81
|
},
|
|
82
82
|
"scripts": {
|
|
83
83
|
"check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
5
|
import type { SubmitEvent } from "react";
|
|
6
|
-
import { useFormContext } from "react-hook-form";
|
|
6
|
+
import { useFormContext, useWatch } from "react-hook-form";
|
|
7
7
|
import { AuthContainer } from "../../container";
|
|
8
8
|
import { Button, type ButtonProps } from "@uniai-fe/uds-primitives";
|
|
9
9
|
import type { AuthCodeInputProps } from "@uniai-fe/uds-primitives";
|
|
@@ -56,7 +56,11 @@ export function AuthFindAccountCodeStep({
|
|
|
56
56
|
form.register(codeFieldName);
|
|
57
57
|
}, [form, codeFieldName]);
|
|
58
58
|
|
|
59
|
-
const codeValue =
|
|
59
|
+
const codeValue =
|
|
60
|
+
useWatch({
|
|
61
|
+
control: form.control,
|
|
62
|
+
name: codeFieldName,
|
|
63
|
+
}) ?? "";
|
|
60
64
|
|
|
61
65
|
const handleCodeValueChange = (nextValue: string) => {
|
|
62
66
|
const digits = sanitizeDigits(nextValue);
|
|
@@ -88,13 +92,11 @@ export function AuthFindAccountCodeStep({
|
|
|
88
92
|
) : undefined);
|
|
89
93
|
|
|
90
94
|
const { onSubmit: formAttrOnSubmit, ...restFormAttr } = formAttr ?? {};
|
|
91
|
-
const handleFormSubmit = (event:
|
|
92
|
-
|
|
93
|
-
event.nativeEvent as unknown as SubmitEvent<HTMLFormElement>;
|
|
94
|
-
formAttrOnSubmit?.(nativeSubmitEvent);
|
|
95
|
+
const handleFormSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
|
96
|
+
formAttrOnSubmit?.(event);
|
|
95
97
|
if (event.defaultPrevented) return;
|
|
96
98
|
event.preventDefault();
|
|
97
|
-
form.handleSubmit(onSubmit)(
|
|
99
|
+
form.handleSubmit(onSubmit)(event);
|
|
98
100
|
};
|
|
99
101
|
|
|
100
102
|
const emailTemplateProps = {
|
|
@@ -62,16 +62,14 @@ export function AuthFindAccountInfoStep({
|
|
|
62
62
|
<FindAccountHeader navigation={navigation} headline={headline} />
|
|
63
63
|
) : undefined);
|
|
64
64
|
|
|
65
|
-
const handleFormSubmit = (event:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
formAttr?.onSubmit?.(nativeEvent);
|
|
69
|
-
if (nativeEvent.defaultPrevented) {
|
|
65
|
+
const handleFormSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
|
66
|
+
formAttr?.onSubmit?.(event);
|
|
67
|
+
if (event.defaultPrevented) {
|
|
70
68
|
event.preventDefault();
|
|
71
69
|
return;
|
|
72
70
|
}
|
|
73
71
|
event.preventDefault();
|
|
74
|
-
form.handleSubmit(onSubmit)(
|
|
72
|
+
form.handleSubmit(onSubmit)(event);
|
|
75
73
|
};
|
|
76
74
|
|
|
77
75
|
return (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
|
-
import { FormProvider, useForm } from "react-hook-form";
|
|
4
|
+
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
|
5
5
|
import { AuthContainer } from "../../common/container";
|
|
6
6
|
import { AuthStageHeader } from "../../common/container/header";
|
|
7
7
|
import { Button } from "@uniai-fe/uds-primitives";
|
|
@@ -43,7 +43,14 @@ export default function FindPasswordStepReset({
|
|
|
43
43
|
defaultValues,
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const [passwordValue, confirmValue] = useWatch({
|
|
47
|
+
control: form.control,
|
|
48
|
+
name: [passwordFieldName, confirmFieldName],
|
|
49
|
+
});
|
|
50
|
+
const watchedValues: FindAccountPasswordValues = {
|
|
51
|
+
[passwordFieldName]: passwordValue ?? "",
|
|
52
|
+
[confirmFieldName]: confirmValue ?? "",
|
|
53
|
+
};
|
|
47
54
|
const resolvedFilled = isSubmittable
|
|
48
55
|
? isSubmittable(watchedValues)
|
|
49
56
|
: Object.values(watchedValues).every(value =>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import { useController, useForm } from "react-hook-form";
|
|
5
5
|
import { Button, CheckboxField } from "@uniai-fe/uds-primitives";
|
|
6
|
-
import type {
|
|
6
|
+
import type { SubmitEventHandler } from "react";
|
|
7
7
|
import type { AuthLoginFieldOptions, AuthLoginFormValues } from "../types";
|
|
8
8
|
import { useAuthLoginForm } from "../hooks";
|
|
9
9
|
import AuthLoginFormFieldId from "./UserId";
|
|
@@ -68,19 +68,23 @@ export default function AuthLoginFormField({
|
|
|
68
68
|
onLogin,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
const {
|
|
71
|
+
const {
|
|
72
|
+
field: {
|
|
73
|
+
onBlur: onKeepLoginBlur,
|
|
74
|
+
onChange: onKeepLoginChange,
|
|
75
|
+
ref: keepLoginRef,
|
|
76
|
+
value: keepLoginValue,
|
|
77
|
+
},
|
|
78
|
+
} = useController({
|
|
72
79
|
control: form.control,
|
|
73
80
|
name: keepLoginFieldName,
|
|
74
81
|
});
|
|
75
82
|
|
|
76
|
-
const handleFormSubmit:
|
|
77
|
-
const nativeEvent =
|
|
78
|
-
event.nativeEvent as unknown as SubmitEvent<HTMLFormElement>;
|
|
79
|
-
|
|
83
|
+
const handleFormSubmit: SubmitEventHandler<HTMLFormElement> = event => {
|
|
80
84
|
// 소비 앱이 formAttr.onSubmit에서 analytics, validation bridge, custom preventDefault 등을
|
|
81
85
|
// 처리할 수 있으므로 템플릿 guard보다 먼저 실행한다.
|
|
82
|
-
formAttr?.onSubmit?.(
|
|
83
|
-
if (event.defaultPrevented
|
|
86
|
+
formAttr?.onSubmit?.(event);
|
|
87
|
+
if (event.defaultPrevented) {
|
|
84
88
|
event.preventDefault();
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
@@ -190,18 +194,18 @@ export default function AuthLoginFormField({
|
|
|
190
194
|
/>
|
|
191
195
|
<CheckboxField
|
|
192
196
|
{...keepLoginCheckboxProps}
|
|
193
|
-
ref={
|
|
197
|
+
ref={keepLoginRef}
|
|
194
198
|
name={keepLoginFieldName}
|
|
195
|
-
checked={
|
|
199
|
+
checked={keepLoginValue === true}
|
|
196
200
|
disabled={isKeepLoginDisabled}
|
|
197
201
|
label={keepLoginLabel}
|
|
198
202
|
helperText={keepLoginHelper}
|
|
199
203
|
fieldClassName={["auth-login-keep-login", keepLoginFieldClassName]
|
|
200
204
|
.filter(Boolean)
|
|
201
205
|
.join(" ")}
|
|
202
|
-
onBlur={
|
|
206
|
+
onBlur={onKeepLoginBlur}
|
|
203
207
|
onCheckedChange={checked => {
|
|
204
|
-
|
|
208
|
+
onKeepLoginChange(checked === true);
|
|
205
209
|
}}
|
|
206
210
|
/>
|
|
207
211
|
<Button.Default
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect,
|
|
3
|
+
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
|
4
4
|
import type {
|
|
5
5
|
CctvRtcStreamOptions,
|
|
6
6
|
CctvRtcStreamScheduleStatus,
|
|
@@ -244,31 +244,26 @@ export function useCctvRtcStreamScheduleStatus({
|
|
|
244
244
|
*/
|
|
245
245
|
scheduler: CctvRtcStreamScheduler;
|
|
246
246
|
}): CctvRtcStreamScheduleStatus {
|
|
247
|
-
const
|
|
247
|
+
const getSnapshot = useCallback(
|
|
248
|
+
(): CctvRtcStreamScheduleStatus =>
|
|
249
|
+
enabled && identityKey ? scheduler.getStatus(identityKey) : "idle",
|
|
250
|
+
[enabled, identityKey, scheduler],
|
|
251
|
+
);
|
|
252
|
+
const status = useSyncExternalStore(
|
|
253
|
+
scheduler.subscribe,
|
|
254
|
+
getSnapshot,
|
|
255
|
+
getSnapshot,
|
|
256
|
+
);
|
|
248
257
|
|
|
249
258
|
useEffect(() => {
|
|
250
259
|
// 필수 identity가 없거나 offline이면 scheduler에 등록하지 않고 idle로 되돌린다.
|
|
251
260
|
if (!enabled || !identityKey) {
|
|
252
|
-
setStatus("idle");
|
|
253
261
|
return;
|
|
254
262
|
}
|
|
255
|
-
|
|
256
|
-
// scheduler mutable state를 React state로 복사해 render가 status 변화를 감지하게 한다.
|
|
257
|
-
const syncStatus = () => {
|
|
258
|
-
setStatus(scheduler.getStatus(identityKey));
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
// subscribe 이후 request해야 request 직후 notify도 놓치지 않는다.
|
|
262
|
-
const unsubscribe = scheduler.subscribe(syncStatus);
|
|
263
263
|
const release = scheduler.request(identityKey);
|
|
264
264
|
|
|
265
|
-
// request 직후 현재 상태를 즉시 반영해 queued/granted 표시 지연을 줄인다.
|
|
266
|
-
syncStatus();
|
|
267
|
-
|
|
268
265
|
return () => {
|
|
269
|
-
// cleanup 순서는 scheduler reference release 후 listener 제거로 둬 release notify를 받을 수 있게 한다.
|
|
270
266
|
release();
|
|
271
|
-
unsubscribe();
|
|
272
267
|
};
|
|
273
268
|
}, [enabled, identityKey, scheduler]);
|
|
274
269
|
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// React 훅들을 활용해 WebRTC 스트림 라이프사이클을 제어한다.
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
5
12
|
import { useSetAtom } from "jotai";
|
|
6
13
|
|
|
7
14
|
// 토큰 발급 쿼리와 API URL 컨텍스트 훅, 타입, react-hook-form 유틸.
|
|
@@ -193,11 +200,8 @@ export function useCctvRtcStream({
|
|
|
193
200
|
// live registry는 여러 hook instance가 같은 cam의 live 상태를 공유하는 Jotai atom이다.
|
|
194
201
|
const setLiveRegistry = useSetAtom(cctvRtcLiveRegistryAtom);
|
|
195
202
|
|
|
196
|
-
// 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 key를
|
|
197
|
-
const
|
|
198
|
-
if (!instanceKeyRef.current) {
|
|
199
|
-
instanceKeyRef.current = Math.random().toString(36).slice(2);
|
|
200
|
-
}
|
|
203
|
+
// 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 stable key를 사용한다.
|
|
204
|
+
const instanceKey = useId();
|
|
201
205
|
|
|
202
206
|
// WebRTC MediaStream을 연결할 video 요소 ref.
|
|
203
207
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
@@ -215,17 +219,17 @@ export function useCctvRtcStream({
|
|
|
215
219
|
const staleTokenRefreshKeyRef = useRef<string | null>(null);
|
|
216
220
|
|
|
217
221
|
// RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
|
|
218
|
-
const [
|
|
222
|
+
const [connectionStateValue, setConnectionState] =
|
|
219
223
|
useState<RTCPeerConnectionState>("new");
|
|
220
224
|
|
|
221
225
|
// 스트림 시도 중 발생한 오류 메시지를 저장한다.
|
|
222
|
-
const [
|
|
226
|
+
const [streamErrorValue, setStreamError] = useState<string | null>(null);
|
|
223
227
|
|
|
224
228
|
// 현재 스트림 연결 절차가 진행 중인지 여부.
|
|
225
|
-
const [
|
|
226
|
-
const [
|
|
227
|
-
const [
|
|
228
|
-
|
|
229
|
+
const [isStreamingValue, setStreaming] = useState(false);
|
|
230
|
+
const [streamStateIdentityKey, setStreamStateIdentityKey] = useState("");
|
|
231
|
+
const [connectedIdentityKey, setConnectedIdentityKey] = useState("");
|
|
232
|
+
const [reconnectReadyKey, setReconnectReadyKey] = useState("");
|
|
229
233
|
|
|
230
234
|
// react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
|
|
231
235
|
const { control } = useFormContext();
|
|
@@ -235,7 +239,7 @@ export function useCctvRtcStream({
|
|
|
235
239
|
whepUsername === undefined ? tokenUsername : whepUsername;
|
|
236
240
|
|
|
237
241
|
// 카메라의 RTC 엔드포인트와 사용자명을 조합해 stable WHEP identity endpoint를 계산한다.
|
|
238
|
-
const streamIdentityEndpoint =
|
|
242
|
+
const streamIdentityEndpoint = (() => {
|
|
239
243
|
// cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
|
|
240
244
|
if (!cam?.cam_rtc) return undefined;
|
|
241
245
|
|
|
@@ -248,9 +252,9 @@ export function useCctvRtcStream({
|
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
return nextEndpoint.toString();
|
|
251
|
-
}
|
|
255
|
+
})();
|
|
252
256
|
|
|
253
|
-
const streamIdentityKey =
|
|
257
|
+
const streamIdentityKey = (() => {
|
|
254
258
|
// identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
|
|
255
259
|
if (
|
|
256
260
|
!tokenUsername ||
|
|
@@ -267,7 +271,13 @@ export function useCctvRtcStream({
|
|
|
267
271
|
cam.cam_id,
|
|
268
272
|
streamIdentityEndpoint,
|
|
269
273
|
].join("|");
|
|
270
|
-
}
|
|
274
|
+
})();
|
|
275
|
+
const hasCurrentStreamState = streamStateIdentityKey === streamIdentityKey;
|
|
276
|
+
const connectionState = hasCurrentStreamState ? connectionStateValue : "new";
|
|
277
|
+
const streamError = hasCurrentStreamState ? streamErrorValue : null;
|
|
278
|
+
const isStreaming = hasCurrentStreamState ? isStreamingValue : false;
|
|
279
|
+
const hasConnected =
|
|
280
|
+
Boolean(streamIdentityKey) && connectedIdentityKey === streamIdentityKey;
|
|
271
281
|
|
|
272
282
|
// scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
|
|
273
283
|
const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
|
|
@@ -292,7 +302,7 @@ export function useCctvRtcStream({
|
|
|
292
302
|
const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
|
|
293
303
|
|
|
294
304
|
// token-specific ref는 실제 WHEP 요청 URL에만 붙이고 stream identity/key에서는 제외한다.
|
|
295
|
-
const requestEndpoint =
|
|
305
|
+
const requestEndpoint = (() => {
|
|
296
306
|
if (!streamIdentityEndpoint || !tokenQuery.data?.session_ref)
|
|
297
307
|
return undefined;
|
|
298
308
|
|
|
@@ -300,9 +310,9 @@ export function useCctvRtcStream({
|
|
|
300
310
|
nextEndpoint.searchParams.set("session_ref", tokenQuery.data.session_ref);
|
|
301
311
|
|
|
302
312
|
return nextEndpoint.toString();
|
|
303
|
-
}
|
|
313
|
+
})();
|
|
304
314
|
|
|
305
|
-
const streamKeyCandidate =
|
|
315
|
+
const streamKeyCandidate = (() => {
|
|
306
316
|
// streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
|
|
307
317
|
if (
|
|
308
318
|
!cam?.cam_id ||
|
|
@@ -320,20 +330,7 @@ export function useCctvRtcStream({
|
|
|
320
330
|
streamIdentityEndpoint,
|
|
321
331
|
tokenQuery.dataUpdatedAt,
|
|
322
332
|
].join("|");
|
|
323
|
-
}
|
|
324
|
-
cam?.cam_id,
|
|
325
|
-
cam?.cam_online,
|
|
326
|
-
cam?.company_id,
|
|
327
|
-
streamIdentityEndpoint,
|
|
328
|
-
tokenQuery.data?.token,
|
|
329
|
-
tokenQuery.dataUpdatedAt,
|
|
330
|
-
tokenUsername,
|
|
331
|
-
]);
|
|
332
|
-
|
|
333
|
-
useEffect(() => {
|
|
334
|
-
// identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
|
|
335
|
-
setHasConnected(false);
|
|
336
|
-
}, [streamIdentityKey]);
|
|
333
|
+
})();
|
|
337
334
|
|
|
338
335
|
const reconnectReason = useMemo(
|
|
339
336
|
() =>
|
|
@@ -354,21 +351,16 @@ export function useCctvRtcStream({
|
|
|
354
351
|
!isTokenLoading &&
|
|
355
352
|
!isStreaming;
|
|
356
353
|
|
|
357
|
-
if (!canStartReconnectTimer)
|
|
358
|
-
// timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
|
|
359
|
-
setPostConnectedReconnectReady(false);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
354
|
+
if (!canStartReconnectTimer) return;
|
|
362
355
|
|
|
363
|
-
|
|
364
|
-
setPostConnectedReconnectReady(false);
|
|
356
|
+
const nextReconnectReadyKey = `${streamIdentityKey}|${reconnectReason}`;
|
|
365
357
|
const reconnectDelayMs = getPostConnectedReconnectDelayMs(
|
|
366
|
-
|
|
358
|
+
nextReconnectReadyKey,
|
|
367
359
|
);
|
|
368
360
|
|
|
369
361
|
// cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
|
|
370
362
|
const timeout = setTimeout(() => {
|
|
371
|
-
|
|
363
|
+
setReconnectReadyKey(nextReconnectReadyKey);
|
|
372
364
|
}, reconnectDelayMs);
|
|
373
365
|
|
|
374
366
|
return () => {
|
|
@@ -432,13 +424,10 @@ export function useCctvRtcStream({
|
|
|
432
424
|
);
|
|
433
425
|
}, [streamKeyCandidate, streamRegistry]);
|
|
434
426
|
|
|
435
|
-
const canUseTokenForStream =
|
|
427
|
+
const canUseTokenForStream = (() => {
|
|
436
428
|
// token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
|
|
437
429
|
if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
|
|
438
430
|
|
|
439
|
-
// 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
|
|
440
|
-
if (activeStreamKeyRef.current === streamKeyCandidate) return true;
|
|
441
|
-
|
|
442
431
|
// registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
|
|
443
432
|
if (hasReusableRegistryStream) return true;
|
|
444
433
|
|
|
@@ -447,12 +436,7 @@ export function useCctvRtcStream({
|
|
|
447
436
|
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
448
437
|
token: tokenQuery.data.token,
|
|
449
438
|
});
|
|
450
|
-
}
|
|
451
|
-
hasReusableRegistryStream,
|
|
452
|
-
streamKeyCandidate,
|
|
453
|
-
tokenQuery.data?.token,
|
|
454
|
-
tokenQuery.dataUpdatedAt,
|
|
455
|
-
]);
|
|
439
|
+
})();
|
|
456
440
|
|
|
457
441
|
// streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
|
|
458
442
|
const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
|
|
@@ -506,10 +490,6 @@ export function useCctvRtcStream({
|
|
|
506
490
|
// DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
|
|
507
491
|
if (currentVideo) currentVideo.srcObject = null;
|
|
508
492
|
|
|
509
|
-
// UI state도 초기 연결 전 상태로 되돌린다.
|
|
510
|
-
setConnectionState("new");
|
|
511
|
-
setStreamError(null);
|
|
512
|
-
setStreaming(false);
|
|
513
493
|
return;
|
|
514
494
|
}
|
|
515
495
|
|
|
@@ -560,13 +540,14 @@ export function useCctvRtcStream({
|
|
|
560
540
|
|
|
561
541
|
// registry snapshot 변경을 hook local state와 DOM video에 반영한다.
|
|
562
542
|
const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
|
|
543
|
+
setStreamStateIdentityKey(streamIdentityKey);
|
|
563
544
|
setConnectionState(snapshot.connectionState);
|
|
564
545
|
setStreamError(snapshot.streamError);
|
|
565
546
|
setStreaming(snapshot.isStreaming);
|
|
566
547
|
|
|
567
548
|
// 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
|
|
568
549
|
if (snapshot.connectionState === "connected") {
|
|
569
|
-
|
|
550
|
+
setConnectedIdentityKey(streamIdentityKey);
|
|
570
551
|
}
|
|
571
552
|
|
|
572
553
|
// onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
|
|
@@ -582,11 +563,6 @@ export function useCctvRtcStream({
|
|
|
582
563
|
|
|
583
564
|
// subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
|
|
584
565
|
const snapshot = streamRegistry.getSnapshot(streamKey);
|
|
585
|
-
setConnectionState(snapshot.connectionState);
|
|
586
|
-
setStreamError(snapshot.streamError);
|
|
587
|
-
setStreaming(snapshot.isStreaming);
|
|
588
|
-
if (snapshot.connectionState === "connected") setHasConnected(true);
|
|
589
|
-
|
|
590
566
|
if (snapshot.stream) {
|
|
591
567
|
// 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
|
|
592
568
|
if (currentVideo.srcObject !== snapshot.stream) {
|
|
@@ -634,11 +610,11 @@ export function useCctvRtcStream({
|
|
|
634
610
|
if (!reconnectReason) return false;
|
|
635
611
|
|
|
636
612
|
// grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
|
|
637
|
-
return
|
|
613
|
+
return reconnectReadyKey === `${streamIdentityKey}|${reconnectReason}`;
|
|
638
614
|
}, [
|
|
639
615
|
cam?.cam_online,
|
|
640
616
|
hasConnected,
|
|
641
|
-
|
|
617
|
+
reconnectReadyKey,
|
|
642
618
|
isStreaming,
|
|
643
619
|
isTokenLoading,
|
|
644
620
|
reconnectReason,
|
|
@@ -759,7 +735,6 @@ export function useCctvRtcStream({
|
|
|
759
735
|
if (!camId) return;
|
|
760
736
|
|
|
761
737
|
// 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
|
|
762
|
-
const instanceKey = instanceKeyRef.current as string;
|
|
763
738
|
setLiveRegistry(prev => {
|
|
764
739
|
// jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
|
|
765
740
|
const next = { ...prev };
|
|
@@ -768,14 +743,13 @@ export function useCctvRtcStream({
|
|
|
768
743
|
next[camId] = perCam;
|
|
769
744
|
return next;
|
|
770
745
|
});
|
|
771
|
-
}, [cam?.cam_id, liveState, setLiveRegistry]);
|
|
746
|
+
}, [cam?.cam_id, instanceKey, liveState, setLiveRegistry]);
|
|
772
747
|
|
|
773
748
|
useEffect(() => {
|
|
774
749
|
// unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
|
|
775
750
|
const camId = cam?.cam_id;
|
|
776
751
|
if (!camId) return;
|
|
777
752
|
|
|
778
|
-
const instanceKey = instanceKeyRef.current as string;
|
|
779
753
|
return () => {
|
|
780
754
|
// 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
|
|
781
755
|
setLiveRegistry(prev => {
|
|
@@ -792,7 +766,7 @@ export function useCctvRtcStream({
|
|
|
792
766
|
return next;
|
|
793
767
|
});
|
|
794
768
|
};
|
|
795
|
-
}, [cam?.cam_id, setLiveRegistry]);
|
|
769
|
+
}, [cam?.cam_id, instanceKey, setLiveRegistry]);
|
|
796
770
|
|
|
797
771
|
// 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
|
|
798
772
|
return {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* CCTV Pagination Carousel Context 값 모양.
|
|
5
5
|
*/
|
|
6
6
|
export interface CctvPaginationCarouselContextValue {
|
|
7
|
-
viewportRef:
|
|
8
|
-
trackRef:
|
|
7
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
8
|
+
trackRef: RefObject<HTMLUListElement | null>;
|
|
9
9
|
onPrev: () => void;
|
|
10
10
|
onNext: () => void;
|
|
11
11
|
moveTo: (index: number) => void;
|
|
@@ -4,7 +4,6 @@ import clsx from "clsx";
|
|
|
4
4
|
import { MobileFrameContainer } from "./Container";
|
|
5
5
|
import { MobileFrameNavigation } from "../navigation";
|
|
6
6
|
import type { MobileFrameProps } from "../../types";
|
|
7
|
-
import React, { useCallback } from "react";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* 모바일 전용 Page Frame 래퍼; 공통 컨테이너 위에 모바일 클래스/스타일을 덧입힌다.
|
|
@@ -22,12 +21,9 @@ export function MobileFrame({
|
|
|
22
21
|
children,
|
|
23
22
|
...rest
|
|
24
23
|
}: MobileFrameProps) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(navigationProps ? <MobileFrameNavigation {...navigationProps} /> : null),
|
|
29
|
-
[footer, navigationProps],
|
|
30
|
-
);
|
|
24
|
+
const resolvedFooter =
|
|
25
|
+
footer ??
|
|
26
|
+
(navigationProps ? <MobileFrameNavigation {...navigationProps} /> : null);
|
|
31
27
|
|
|
32
28
|
return (
|
|
33
29
|
<MobileFrameContainer
|
|
@@ -36,7 +32,7 @@ export function MobileFrame({
|
|
|
36
32
|
header={header}
|
|
37
33
|
headerClassName={clsx("page-frame-mobile-header", headerClassName)}
|
|
38
34
|
bodyClassName={clsx("page-frame-mobile-body", bodyClassName)}
|
|
39
|
-
footer={
|
|
35
|
+
footer={resolvedFooter}
|
|
40
36
|
footerClassName={clsx("page-frame-mobile-footer", footerClassName)}
|
|
41
37
|
>
|
|
42
38
|
{children}
|
|
@@ -5,7 +5,7 @@ import { Chip, Form, Input } from "@uniai-fe/uds-primitives";
|
|
|
5
5
|
import { useEffect } from "react";
|
|
6
6
|
import type { ServiceInquiryFormProps } from "../types";
|
|
7
7
|
import type { ServiceInquiryFormValues, ServiceInquiryType } from "../types";
|
|
8
|
-
import { useFormContext } from "react-hook-form";
|
|
8
|
+
import { useFormContext, useWatch } from "react-hook-form";
|
|
9
9
|
|
|
10
10
|
const DEFAULT_INQUIRY_TYPE_OPTIONS: ServiceInquiryType[] = [
|
|
11
11
|
"접속이 안 돼요",
|
|
@@ -38,7 +38,10 @@ const ServiceInquiryForm = ({
|
|
|
38
38
|
onSubmit,
|
|
39
39
|
}: ServiceInquiryFormProps) => {
|
|
40
40
|
const form = useFormContext<ServiceInquiryFormValues>();
|
|
41
|
-
const selectedInquiryType =
|
|
41
|
+
const selectedInquiryType = useWatch({
|
|
42
|
+
control: form.control,
|
|
43
|
+
name: "inquiry_type",
|
|
44
|
+
});
|
|
42
45
|
const inquiryTypeOptions =
|
|
43
46
|
inquiryTypeField?.options ?? DEFAULT_INQUIRY_TYPE_OPTIONS;
|
|
44
47
|
const farmNameState = form.getFieldState("farm_name", form.formState);
|