@uniai-fe/uds-templates 0.6.27 → 0.6.29
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/dist/styles.css +27 -1
- package/package.json +7 -7
- package/src/cctv/apis/client.ts +119 -11
- package/src/cctv/hooks/useRtcStream.ts +4 -2
- package/src/cctv/styles/cam-list.scss +28 -2
- package/src/cctv/styles/variables.scss +1 -0
- package/src/modal/components/alert/Template.tsx +1 -0
- package/src/modal/components/dialog/Template.tsx +1 -0
- package/src/modal/styles/container.scss +4 -0
package/dist/styles.css
CHANGED
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
/* Card layout */
|
|
95
95
|
--cctv-video-radius: 12px;
|
|
96
96
|
--cctv-list-gap: var(--spacing-gap-5);
|
|
97
|
+
--cctv-list-item-min-width: 320px;
|
|
97
98
|
--cctv-overlay-padding-x: var(--spacing-padding-8);
|
|
98
99
|
--cctv-overlay-padding-y: var(--spacing-padding-6);
|
|
99
100
|
--cctv-video-bg: var(--color-surface-heavy);
|
|
@@ -632,6 +633,10 @@
|
|
|
632
633
|
width: 100%;
|
|
633
634
|
}
|
|
634
635
|
|
|
636
|
+
.uds-modal-root.uds-modal-alert .uds-modal-footer-center {
|
|
637
|
+
gap: 0;
|
|
638
|
+
}
|
|
639
|
+
|
|
635
640
|
.uds-modal-footer-button[data-width=full] {
|
|
636
641
|
flex: 1 1 100%;
|
|
637
642
|
max-width: 100%;
|
|
@@ -1873,6 +1878,7 @@
|
|
|
1873
1878
|
}
|
|
1874
1879
|
|
|
1875
1880
|
.cctv-cam-list-container {
|
|
1881
|
+
container: cctv-cam-list/inline-size;
|
|
1876
1882
|
width: 100%;
|
|
1877
1883
|
height: 100%;
|
|
1878
1884
|
overflow-x: hidden;
|
|
@@ -1888,7 +1894,7 @@
|
|
|
1888
1894
|
.cctv-cam-list-track {
|
|
1889
1895
|
display: grid;
|
|
1890
1896
|
width: 100%;
|
|
1891
|
-
grid-template-columns: repeat(auto-fit, minmax(
|
|
1897
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--cctv-list-item-min-width)), 1fr));
|
|
1892
1898
|
gap: var(--cctv-list-gap);
|
|
1893
1899
|
padding: 0;
|
|
1894
1900
|
margin: 0;
|
|
@@ -1902,6 +1908,26 @@
|
|
|
1902
1908
|
max-height: 240px;
|
|
1903
1909
|
}
|
|
1904
1910
|
|
|
1911
|
+
@container cctv-cam-list (min-width: 652px) {
|
|
1912
|
+
.cctv-cam-list-track {
|
|
1913
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
@container cctv-cam-list (min-width: 984px) {
|
|
1917
|
+
.cctv-cam-list-track {
|
|
1918
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
@container cctv-cam-list (min-width: 1316px) {
|
|
1922
|
+
.cctv-cam-list-track {
|
|
1923
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
@container cctv-cam-list (min-width: 1648px) {
|
|
1927
|
+
.cctv-cam-list-track {
|
|
1928
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1905
1931
|
.cctv-pagination-container {
|
|
1906
1932
|
width: 100%;
|
|
1907
1933
|
display: flex;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-templates",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.29",
|
|
4
4
|
"description": "UNIAI Design System; UI Templates Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@svgr/webpack": "^8.1.0",
|
|
75
|
-
"@tanstack/react-query": "^5.101.
|
|
75
|
+
"@tanstack/react-query": "^5.101.1",
|
|
76
76
|
"@types/node": "^24.12.3",
|
|
77
|
-
"@types/react": "^19.2.
|
|
77
|
+
"@types/react": "^19.2.17",
|
|
78
78
|
"@types/react-dom": "^19.2.3",
|
|
79
79
|
"@uniai-fe/eslint-config": "workspace:*",
|
|
80
80
|
"@uniai-fe/next-devkit": "workspace:*",
|
|
@@ -88,11 +88,11 @@
|
|
|
88
88
|
"@uniai-fe/util-next": "workspace:*",
|
|
89
89
|
"@uniai-fe/util-rtc": "workspace:*",
|
|
90
90
|
"eslint": "^9.39.2",
|
|
91
|
-
"jotai": "^2.20.
|
|
91
|
+
"jotai": "^2.20.1",
|
|
92
92
|
"next": "^15.5.18",
|
|
93
|
-
"prettier": "^3.8.
|
|
94
|
-
"react-hook-form": "^7.
|
|
95
|
-
"sass": "^1.
|
|
93
|
+
"prettier": "^3.8.4",
|
|
94
|
+
"react-hook-form": "^7.80.0",
|
|
95
|
+
"sass": "^1.101.0",
|
|
96
96
|
"typescript": "5.9.3"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/cctv/apis/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
|
4
|
+
import { getApiError } from "@uniai-fe/util-api";
|
|
4
5
|
import type {
|
|
5
6
|
API_Req_CctvRtcToken,
|
|
6
7
|
API_Res_CctvCompany,
|
|
@@ -21,6 +22,86 @@ export const CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
21
22
|
*/
|
|
22
23
|
export const CCTV_RTC_TOKEN_GC_TIME_MS = CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* CCTV; RTC token request error
|
|
27
|
+
* @property {number} [statusCode] service token route HTTP status
|
|
28
|
+
* @desc
|
|
29
|
+
* fetch 성공 여부와 API payload 실패 여부를 React Query error 상태로 통일하기 위한 내부 error다.
|
|
30
|
+
*/
|
|
31
|
+
class CctvRtcTokenRequestError extends Error {
|
|
32
|
+
readonly statusCode?: number;
|
|
33
|
+
|
|
34
|
+
constructor(message: string, statusCode?: number) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "CctvRtcTokenRequestError";
|
|
37
|
+
this.statusCode = statusCode;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* CCTV; RTC token direct response guard
|
|
43
|
+
* @param {unknown} payload 검사할 token response payload
|
|
44
|
+
* @returns {payload is API_Res_CctvRtcToken} direct `{ token }` 응답 여부
|
|
45
|
+
*/
|
|
46
|
+
const isCctvRtcTokenResponse = (
|
|
47
|
+
payload: unknown,
|
|
48
|
+
): payload is API_Res_CctvRtcToken => {
|
|
49
|
+
if (!payload || typeof payload !== "object") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nextPayload = payload as Partial<API_Res_CctvRtcToken>;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
typeof nextPayload.token === "string" && nextPayload.token.trim() !== ""
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CCTV; RTC token payload normalizer
|
|
62
|
+
* @param {unknown} payload service token route 응답 payload
|
|
63
|
+
* @returns {API_Res_CctvRtcToken | null} 사용 가능한 token 응답
|
|
64
|
+
* @desc
|
|
65
|
+
* service route가 direct `{ token }` 또는 API base `{ data: { token } }`를 반환하는 경우를 모두 소비한다.
|
|
66
|
+
*/
|
|
67
|
+
const getCctvRtcTokenResponse = (
|
|
68
|
+
payload: unknown,
|
|
69
|
+
): API_Res_CctvRtcToken | null => {
|
|
70
|
+
if (isCctvRtcTokenResponse(payload)) {
|
|
71
|
+
return payload;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!payload || typeof payload !== "object") {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nextPayload = payload as { data?: unknown };
|
|
79
|
+
|
|
80
|
+
return isCctvRtcTokenResponse(nextPayload.data) ? nextPayload.data : null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* CCTV; RTC token query retry policy
|
|
85
|
+
* @param {number} failureCount 현재 실패 횟수
|
|
86
|
+
* @param {Error} error queryFn에서 throw된 error
|
|
87
|
+
* @returns {boolean} 재시도 여부
|
|
88
|
+
* @desc
|
|
89
|
+
* 인증/권한/config/응답 계약 오류는 재시도하지 않고, 5xx 또는 network error만 1회 재시도한다.
|
|
90
|
+
*/
|
|
91
|
+
const shouldRetryCctvRtcTokenQuery = (
|
|
92
|
+
failureCount: number,
|
|
93
|
+
error: Error,
|
|
94
|
+
): boolean => {
|
|
95
|
+
if (
|
|
96
|
+
error instanceof CctvRtcTokenRequestError &&
|
|
97
|
+
(error.statusCode === undefined || error.statusCode < 500)
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return failureCount < 1;
|
|
103
|
+
};
|
|
104
|
+
|
|
24
105
|
/**
|
|
25
106
|
* CCTV; client company-list fetcher
|
|
26
107
|
* @param {object} params 요청 파라미터
|
|
@@ -64,23 +145,49 @@ export const useQueryCctvCompanyList = ({
|
|
|
64
145
|
* @param {string} params.username CCTV token 권한 확인용 계정명
|
|
65
146
|
* @param {string} [params.url] service에서 주입한 token API 경로
|
|
66
147
|
* @returns {Promise<API_Res_CctvRtcToken>} RTC token 응답
|
|
148
|
+
* @desc
|
|
149
|
+
* - UDS는 기본 token route로 fallback하지 않고 service가 주입한 `url`만 호출한다.
|
|
150
|
+
* - body의 `company_id`, `cam_id`, `username`은 token route가 권한 검증에 사용할 client-visible 식별자다.
|
|
151
|
+
* - service route는 이 값을 신뢰하지 말고 authenticated session 기준으로 접근 권한을 재검증해야 한다.
|
|
67
152
|
*/
|
|
68
153
|
export const postCctvRtcToken = async ({
|
|
69
154
|
company_id,
|
|
70
155
|
cam_id,
|
|
71
156
|
username,
|
|
72
157
|
url,
|
|
73
|
-
}: API_Req_CctvRtcToken & { url?: string }): Promise<API_Res_CctvRtcToken> =>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
158
|
+
}: API_Req_CctvRtcToken & { url?: string }): Promise<API_Res_CctvRtcToken> => {
|
|
159
|
+
if (!url) {
|
|
160
|
+
throw new CctvRtcTokenRequestError("CCTV token URL is required.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const response = await fetch(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: {
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
},
|
|
168
|
+
body: JSON.stringify({ company_id, cam_id, username }),
|
|
169
|
+
});
|
|
170
|
+
const payload: unknown = await response.json().catch(() => undefined);
|
|
171
|
+
const apiError = getApiError(response, payload);
|
|
172
|
+
|
|
173
|
+
if (apiError) {
|
|
174
|
+
throw new CctvRtcTokenRequestError(
|
|
175
|
+
apiError.message ?? "CCTV token request failed.",
|
|
176
|
+
apiError.code,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const tokenResponse = getCctvRtcTokenResponse(payload);
|
|
181
|
+
|
|
182
|
+
if (!tokenResponse) {
|
|
183
|
+
throw new CctvRtcTokenRequestError(
|
|
184
|
+
"CCTV token response is missing token.",
|
|
185
|
+
response.status,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return tokenResponse;
|
|
190
|
+
};
|
|
84
191
|
|
|
85
192
|
/**
|
|
86
193
|
* CCTV; RTC token query hook
|
|
@@ -112,6 +219,7 @@ export const useQueryCctvRtcToken = ({
|
|
|
112
219
|
// exp decode가 불가한 token은 fallback age를 넘기면 신규 WHEP 연결에 쓰지 않는다.
|
|
113
220
|
staleTime: CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
|
|
114
221
|
gcTime: CCTV_RTC_TOKEN_GC_TIME_MS,
|
|
222
|
+
retry: shouldRetryCctvRtcTokenQuery,
|
|
115
223
|
// focus/reconnect/mount가 곧바로 다수 WHEP 재협상으로 이어지지 않도록 자동 refetch를 막는다.
|
|
116
224
|
refetchOnMount: false,
|
|
117
225
|
refetchOnReconnect: false,
|
|
@@ -182,6 +182,7 @@ export function useCctvRtcStream({
|
|
|
182
182
|
}: UseCctvRtcStreamParams): UseCctvRtcStreamReturn {
|
|
183
183
|
// Provider를 통해 주입된 기본 토큰 발급 URL을 확보한다.
|
|
184
184
|
const { tokenUrl: contextTokenUrl } = useCctvApiUrl();
|
|
185
|
+
const tokenEndpointUrl = tokenUrl ?? contextTokenUrl;
|
|
185
186
|
|
|
186
187
|
// registry는 WHEP connection/MediaStream을 Provider scope에서 유지한다.
|
|
187
188
|
const streamRegistry = useCctvRtcStreamRegistry();
|
|
@@ -270,12 +271,13 @@ export function useCctvRtcStream({
|
|
|
270
271
|
cam_id: cam?.cam_id ?? "",
|
|
271
272
|
enabled: isStreamStartGranted,
|
|
272
273
|
username: tokenUsername,
|
|
273
|
-
url:
|
|
274
|
+
url: tokenEndpointUrl,
|
|
274
275
|
});
|
|
275
276
|
|
|
276
277
|
const { refetch: refetchRtcToken } = tokenQuery;
|
|
277
278
|
const isTokenLoading = tokenQuery.isFetching;
|
|
278
|
-
const
|
|
279
|
+
const isTokenEndpointMissing = isStreamStartGranted && !tokenEndpointUrl;
|
|
280
|
+
const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
|
|
279
281
|
|
|
280
282
|
const streamKeyCandidate = useMemo(() => {
|
|
281
283
|
// streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
.cctv-cam-list-container {
|
|
2
|
+
container: cctv-cam-list / inline-size;
|
|
2
3
|
width: 100%;
|
|
3
4
|
height: 100%;
|
|
4
5
|
|
|
@@ -19,8 +20,8 @@
|
|
|
19
20
|
width: 100%;
|
|
20
21
|
grid-template-columns: repeat(
|
|
21
22
|
auto-fit,
|
|
22
|
-
minmax(
|
|
23
|
-
);
|
|
23
|
+
minmax(min(100%, var(--cctv-list-item-min-width)), 1fr)
|
|
24
|
+
);
|
|
24
25
|
gap: var(--cctv-list-gap);
|
|
25
26
|
padding: 0;
|
|
26
27
|
margin: 0;
|
|
@@ -33,3 +34,28 @@
|
|
|
33
34
|
.cctv-cam-list-item .cctv-video-container {
|
|
34
35
|
max-height: 240px;
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
// 4:3 card ratio는 video container가 책임지고, list는 320px card + 12px gap 기준으로 열 수만 조정한다.
|
|
39
|
+
@container cctv-cam-list (min-width: 652px) {
|
|
40
|
+
.cctv-cam-list-track {
|
|
41
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@container cctv-cam-list (min-width: 984px) {
|
|
46
|
+
.cctv-cam-list-track {
|
|
47
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@container cctv-cam-list (min-width: 1316px) {
|
|
52
|
+
.cctv-cam-list-track {
|
|
53
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@container cctv-cam-list (min-width: 1648px) {
|
|
58
|
+
.cctv-cam-list-track {
|
|
59
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
60
|
+
}
|
|
61
|
+
}
|