@uniai-fe/uds-templates 0.9.0 → 0.10.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/package.json +7 -7
- 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 +40 -66
- 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.0",
|
|
4
4
|
"description": "UNIAI Design System; UI Templates Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -37,7 +37,7 @@
|
|
|
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",
|
|
@@ -67,15 +67,15 @@
|
|
|
67
67
|
"react-hook-form": "^7.80.0",
|
|
68
68
|
"sass": "^1.101.0",
|
|
69
69
|
"typescript": "6.0.3",
|
|
70
|
-
"@uniai-fe/eslint-config": "0.
|
|
71
|
-
"@uniai-fe/react-hooks": "0.2.0",
|
|
70
|
+
"@uniai-fe/eslint-config": "0.4.0",
|
|
72
71
|
"@uniai-fe/next-devkit": "0.4.0",
|
|
73
72
|
"@uniai-fe/tsconfig": "0.2.0",
|
|
74
|
-
"@uniai-fe/
|
|
75
|
-
"@uniai-fe/uds-primitives": "0.
|
|
76
|
-
"@uniai-fe/util-api": "0.2.1",
|
|
73
|
+
"@uniai-fe/react-hooks": "0.3.0",
|
|
74
|
+
"@uniai-fe/uds-primitives": "0.10.0",
|
|
77
75
|
"@uniai-fe/util-functions": "0.4.1",
|
|
76
|
+
"@uniai-fe/uds-foundation": "0.5.0",
|
|
78
77
|
"@uniai-fe/util-jotai": "0.3.0",
|
|
78
|
+
"@uniai-fe/util-api": "0.2.1",
|
|
79
79
|
"@uniai-fe/util-next": "0.5.0",
|
|
80
80
|
"@uniai-fe/util-rtc": "0.2.0"
|
|
81
81
|
},
|
|
@@ -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 엔드포인트와 사용자명을 조합해 실제 WHEP endpoint를 계산한다.
|
|
238
|
-
const endpoint =
|
|
242
|
+
const endpoint = (() => {
|
|
239
243
|
// cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
|
|
240
244
|
if (!cam?.cam_rtc) return undefined;
|
|
241
245
|
|
|
@@ -246,16 +250,22 @@ export function useCctvRtcStream({
|
|
|
246
250
|
|
|
247
251
|
// cam_rtc 뒤의 slash 중복을 제거하고 WHEP path를 붙인다.
|
|
248
252
|
return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
|
|
249
|
-
}
|
|
253
|
+
})();
|
|
250
254
|
|
|
251
|
-
const streamIdentityKey =
|
|
255
|
+
const streamIdentityKey = (() => {
|
|
252
256
|
// identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
|
|
253
257
|
if (!tokenUsername || !cam?.company_id || !cam.cam_id || !endpoint)
|
|
254
258
|
return "";
|
|
255
259
|
|
|
256
260
|
// token 갱신 시각은 제외해 같은 카메라 stream family를 하나의 identity로 묶는다.
|
|
257
261
|
return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
|
|
258
|
-
}
|
|
262
|
+
})();
|
|
263
|
+
const hasCurrentStreamState = streamStateIdentityKey === streamIdentityKey;
|
|
264
|
+
const connectionState = hasCurrentStreamState ? connectionStateValue : "new";
|
|
265
|
+
const streamError = hasCurrentStreamState ? streamErrorValue : null;
|
|
266
|
+
const isStreaming = hasCurrentStreamState ? isStreamingValue : false;
|
|
267
|
+
const hasConnected =
|
|
268
|
+
Boolean(streamIdentityKey) && connectedIdentityKey === streamIdentityKey;
|
|
259
269
|
|
|
260
270
|
// scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
|
|
261
271
|
const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
|
|
@@ -279,7 +289,7 @@ export function useCctvRtcStream({
|
|
|
279
289
|
const isTokenEndpointMissing = isStreamStartGranted && !tokenEndpointUrl;
|
|
280
290
|
const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
|
|
281
291
|
|
|
282
|
-
const streamKeyCandidate =
|
|
292
|
+
const streamKeyCandidate = (() => {
|
|
283
293
|
// streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
|
|
284
294
|
if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
|
|
285
295
|
return "";
|
|
@@ -292,20 +302,7 @@ export function useCctvRtcStream({
|
|
|
292
302
|
endpoint,
|
|
293
303
|
tokenQuery.dataUpdatedAt,
|
|
294
304
|
].join("|");
|
|
295
|
-
}
|
|
296
|
-
cam?.cam_id,
|
|
297
|
-
cam?.cam_online,
|
|
298
|
-
cam?.company_id,
|
|
299
|
-
endpoint,
|
|
300
|
-
tokenQuery.data?.token,
|
|
301
|
-
tokenQuery.dataUpdatedAt,
|
|
302
|
-
tokenUsername,
|
|
303
|
-
]);
|
|
304
|
-
|
|
305
|
-
useEffect(() => {
|
|
306
|
-
// identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
|
|
307
|
-
setHasConnected(false);
|
|
308
|
-
}, [streamIdentityKey]);
|
|
305
|
+
})();
|
|
309
306
|
|
|
310
307
|
const reconnectReason = useMemo(
|
|
311
308
|
() =>
|
|
@@ -326,21 +323,16 @@ export function useCctvRtcStream({
|
|
|
326
323
|
!isTokenLoading &&
|
|
327
324
|
!isStreaming;
|
|
328
325
|
|
|
329
|
-
if (!canStartReconnectTimer)
|
|
330
|
-
// timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
|
|
331
|
-
setPostConnectedReconnectReady(false);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
326
|
+
if (!canStartReconnectTimer) return;
|
|
334
327
|
|
|
335
|
-
|
|
336
|
-
setPostConnectedReconnectReady(false);
|
|
328
|
+
const nextReconnectReadyKey = `${streamIdentityKey}|${reconnectReason}`;
|
|
337
329
|
const reconnectDelayMs = getPostConnectedReconnectDelayMs(
|
|
338
|
-
|
|
330
|
+
nextReconnectReadyKey,
|
|
339
331
|
);
|
|
340
332
|
|
|
341
333
|
// cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
|
|
342
334
|
const timeout = setTimeout(() => {
|
|
343
|
-
|
|
335
|
+
setReconnectReadyKey(nextReconnectReadyKey);
|
|
344
336
|
}, reconnectDelayMs);
|
|
345
337
|
|
|
346
338
|
return () => {
|
|
@@ -404,13 +396,10 @@ export function useCctvRtcStream({
|
|
|
404
396
|
);
|
|
405
397
|
}, [streamKeyCandidate, streamRegistry]);
|
|
406
398
|
|
|
407
|
-
const canUseTokenForStream =
|
|
399
|
+
const canUseTokenForStream = (() => {
|
|
408
400
|
// token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
|
|
409
401
|
if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
|
|
410
402
|
|
|
411
|
-
// 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
|
|
412
|
-
if (activeStreamKeyRef.current === streamKeyCandidate) return true;
|
|
413
|
-
|
|
414
403
|
// registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
|
|
415
404
|
if (hasReusableRegistryStream) return true;
|
|
416
405
|
|
|
@@ -419,12 +408,7 @@ export function useCctvRtcStream({
|
|
|
419
408
|
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
420
409
|
token: tokenQuery.data.token,
|
|
421
410
|
});
|
|
422
|
-
}
|
|
423
|
-
hasReusableRegistryStream,
|
|
424
|
-
streamKeyCandidate,
|
|
425
|
-
tokenQuery.data?.token,
|
|
426
|
-
tokenQuery.dataUpdatedAt,
|
|
427
|
-
]);
|
|
411
|
+
})();
|
|
428
412
|
|
|
429
413
|
// streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
|
|
430
414
|
const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
|
|
@@ -478,10 +462,6 @@ export function useCctvRtcStream({
|
|
|
478
462
|
// DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
|
|
479
463
|
if (currentVideo) currentVideo.srcObject = null;
|
|
480
464
|
|
|
481
|
-
// UI state도 초기 연결 전 상태로 되돌린다.
|
|
482
|
-
setConnectionState("new");
|
|
483
|
-
setStreamError(null);
|
|
484
|
-
setStreaming(false);
|
|
485
465
|
return;
|
|
486
466
|
}
|
|
487
467
|
|
|
@@ -527,13 +507,14 @@ export function useCctvRtcStream({
|
|
|
527
507
|
|
|
528
508
|
// registry snapshot 변경을 hook local state와 DOM video에 반영한다.
|
|
529
509
|
const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
|
|
510
|
+
setStreamStateIdentityKey(streamIdentityKey);
|
|
530
511
|
setConnectionState(snapshot.connectionState);
|
|
531
512
|
setStreamError(snapshot.streamError);
|
|
532
513
|
setStreaming(snapshot.isStreaming);
|
|
533
514
|
|
|
534
515
|
// 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
|
|
535
516
|
if (snapshot.connectionState === "connected") {
|
|
536
|
-
|
|
517
|
+
setConnectedIdentityKey(streamIdentityKey);
|
|
537
518
|
}
|
|
538
519
|
|
|
539
520
|
// onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
|
|
@@ -549,11 +530,6 @@ export function useCctvRtcStream({
|
|
|
549
530
|
|
|
550
531
|
// subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
|
|
551
532
|
const snapshot = streamRegistry.getSnapshot(streamKey);
|
|
552
|
-
setConnectionState(snapshot.connectionState);
|
|
553
|
-
setStreamError(snapshot.streamError);
|
|
554
|
-
setStreaming(snapshot.isStreaming);
|
|
555
|
-
if (snapshot.connectionState === "connected") setHasConnected(true);
|
|
556
|
-
|
|
557
533
|
if (snapshot.stream) {
|
|
558
534
|
// 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
|
|
559
535
|
if (currentVideo.srcObject !== snapshot.stream) {
|
|
@@ -601,11 +577,11 @@ export function useCctvRtcStream({
|
|
|
601
577
|
if (!reconnectReason) return false;
|
|
602
578
|
|
|
603
579
|
// grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
|
|
604
|
-
return
|
|
580
|
+
return reconnectReadyKey === `${streamIdentityKey}|${reconnectReason}`;
|
|
605
581
|
}, [
|
|
606
582
|
cam?.cam_online,
|
|
607
583
|
hasConnected,
|
|
608
|
-
|
|
584
|
+
reconnectReadyKey,
|
|
609
585
|
isStreaming,
|
|
610
586
|
isTokenLoading,
|
|
611
587
|
reconnectReason,
|
|
@@ -726,7 +702,6 @@ export function useCctvRtcStream({
|
|
|
726
702
|
if (!camId) return;
|
|
727
703
|
|
|
728
704
|
// 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
|
|
729
|
-
const instanceKey = instanceKeyRef.current as string;
|
|
730
705
|
setLiveRegistry(prev => {
|
|
731
706
|
// jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
|
|
732
707
|
const next = { ...prev };
|
|
@@ -735,14 +710,13 @@ export function useCctvRtcStream({
|
|
|
735
710
|
next[camId] = perCam;
|
|
736
711
|
return next;
|
|
737
712
|
});
|
|
738
|
-
}, [cam?.cam_id, liveState, setLiveRegistry]);
|
|
713
|
+
}, [cam?.cam_id, instanceKey, liveState, setLiveRegistry]);
|
|
739
714
|
|
|
740
715
|
useEffect(() => {
|
|
741
716
|
// unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
|
|
742
717
|
const camId = cam?.cam_id;
|
|
743
718
|
if (!camId) return;
|
|
744
719
|
|
|
745
|
-
const instanceKey = instanceKeyRef.current as string;
|
|
746
720
|
return () => {
|
|
747
721
|
// 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
|
|
748
722
|
setLiveRegistry(prev => {
|
|
@@ -759,7 +733,7 @@ export function useCctvRtcStream({
|
|
|
759
733
|
return next;
|
|
760
734
|
});
|
|
761
735
|
};
|
|
762
|
-
}, [cam?.cam_id, setLiveRegistry]);
|
|
736
|
+
}, [cam?.cam_id, instanceKey, setLiveRegistry]);
|
|
763
737
|
|
|
764
738
|
// 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
|
|
765
739
|
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);
|