@uniai-fe/uds-templates 0.6.27 → 0.6.28

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 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);
@@ -1873,6 +1874,7 @@
1873
1874
  }
1874
1875
 
1875
1876
  .cctv-cam-list-container {
1877
+ container: cctv-cam-list/inline-size;
1876
1878
  width: 100%;
1877
1879
  height: 100%;
1878
1880
  overflow-x: hidden;
@@ -1888,7 +1890,7 @@
1888
1890
  .cctv-cam-list-track {
1889
1891
  display: grid;
1890
1892
  width: 100%;
1891
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1893
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--cctv-list-item-min-width)), 1fr));
1892
1894
  gap: var(--cctv-list-gap);
1893
1895
  padding: 0;
1894
1896
  margin: 0;
@@ -1902,6 +1904,26 @@
1902
1904
  max-height: 240px;
1903
1905
  }
1904
1906
 
1907
+ @container cctv-cam-list (min-width: 652px) {
1908
+ .cctv-cam-list-track {
1909
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1910
+ }
1911
+ }
1912
+ @container cctv-cam-list (min-width: 984px) {
1913
+ .cctv-cam-list-track {
1914
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1915
+ }
1916
+ }
1917
+ @container cctv-cam-list (min-width: 1316px) {
1918
+ .cctv-cam-list-track {
1919
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1920
+ }
1921
+ }
1922
+ @container cctv-cam-list (min-width: 1648px) {
1923
+ .cctv-cam-list-track {
1924
+ grid-template-columns: repeat(5, minmax(0, 1fr));
1925
+ }
1926
+ }
1905
1927
  .cctv-pagination-container {
1906
1928
  width: 100%;
1907
1929
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.27",
3
+ "version": "0.6.28",
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.0",
75
+ "@tanstack/react-query": "^5.101.1",
76
76
  "@types/node": "^24.12.3",
77
- "@types/react": "^19.2.16",
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.0",
91
+ "jotai": "^2.20.1",
92
92
  "next": "^15.5.18",
93
- "prettier": "^3.8.3",
94
- "react-hook-form": "^7.77.0",
95
- "sass": "^1.100.0",
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
  }
@@ -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
- await // token route는 service-local proxy가 있으면 그 경로를 우선 사용한다.
75
- (
76
- await fetch(url ?? "/api/cctv/token", {
77
- method: "POST",
78
- headers: {
79
- "Content-Type": "application/json",
80
- },
81
- body: JSON.stringify({ company_id, cam_id, username }),
82
- })
83
- ).json();
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: tokenUrl ?? contextTokenUrl,
274
+ url: tokenEndpointUrl,
274
275
  });
275
276
 
276
277
  const { refetch: refetchRtcToken } = tokenQuery;
277
278
  const isTokenLoading = tokenQuery.isFetching;
278
- const isTokenError = tokenQuery.isError;
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(320px, 1fr)
23
- ); // 최소 너비 비율을 4:3으로 맞춤
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
+ }
@@ -2,6 +2,7 @@
2
2
  /* Card layout */
3
3
  --cctv-video-radius: 12px;
4
4
  --cctv-list-gap: var(--spacing-gap-5);
5
+ --cctv-list-item-min-width: 320px;
5
6
 
6
7
  --cctv-overlay-padding-x: var(--spacing-padding-8);
7
8
  --cctv-overlay-padding-y: var(--spacing-padding-6);