@uniai-fe/uds-templates 0.6.1 → 0.6.3

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/README.md CHANGED
@@ -11,7 +11,7 @@ Next.js 서비스에서 primitives와 동일한 방식으로 **Raw TypeScript**
11
11
  - `@uniai-fe/uds-foundation@^0.0.1` (CSS 토큰 + reset)
12
12
  - `@uniai-fe/uds-primitives@^0.0.2` (템플릿 내부 UI 컴포넌트)
13
13
  - `react` `>= 19`, `react-dom` `>= 19`
14
- - Storybook/로컬 개발용으로는 `@uniai-fe/uds-foundation/css`를루트에서 번만 import 해야 합니다.
14
+ - Storybook/로컬 개발과 서비스모두 style load 순서를 명시해야 합니다. 자세한 기준은 아래 "스타일 전역 주입"을 따릅니다.
15
15
 
16
16
  > 템플릿은 primitives 위에서 동작하므로, `@uniai-fe/uds-primitives`가 설치되어 있지 않으면 빌드 타임에 peer dependency 경고가 발생합니다.
17
17
 
@@ -21,16 +21,30 @@ Next.js 서비스에서 primitives와 동일한 방식으로 **Raw TypeScript**
21
21
  - 로그인/회원가입/아이디·비밀번호 찾기 등 `/auth/**`
22
22
  - 로그인 이후 화면을 위한 모바일 페이지 프레임 `/page-frame/mobile`(PC는 추후 `/page-frame/pc`)
23
23
  - Raw TS 배포:
24
- - `package.json` `exports`가 `src/**`를 가리키며, 번들 없이 즉시 import 가능한 형태로 제공합니다.
24
+ - `package.json` root export가 `src/index.tsx`를 가리키며, 번들 없이 root namespace에서 즉시 import 가능한 형태로 제공합니다.
25
25
  - 디자인시스템 일관성:
26
- - 스타일은 항상 `@uniai-fe/uds-foundation/css`에서 제공하는 CSS 변수(토큰)를 통해만 정의됩니다.
26
+ - 스타일은 foundation style entry가 제공하는 CSS 변수(토큰)를 통해만 정의됩니다.
27
27
  - UI 요소는 모두 `@uniai-fe/uds-primitives` 컴포넌트를 조합해 구성합니다.
28
28
  - 역할 분리:
29
29
  - **templates**는 레이아웃/플로우/상태 표현까지 담당하고,
30
30
  - API 호출, 인증 토큰 관리, 라우팅, i18n 등 비즈니스 로직은 서비스 앱에서 구현합니다.
31
31
 
32
+ ## Public import surface
33
+
34
+ `@uniai-fe/uds-templates`의 공식 안정 public surface는 root namespace import다.
35
+
36
+ ```tsx
37
+ import { Auth, Modal, Frame } from "@uniai-fe/uds-templates";
38
+ ```
39
+
40
+ - `@uniai-fe/uds-templates/auth`, `@uniai-fe/uds-templates/modal`, `@uniai-fe/uds-templates/page-frame` 같은 category subpath는 현재 package export map에 없으므로 공식 사용 예시로 안내하지 않는다.
41
+ - root에는 UI template namespace뿐 아니라 일부 API/helper/state surface도 함께 포함되어 있다. 이 책임 경계는 숨기지 않고 후속 package/API contract gate에서 정리한다.
42
+ - category subpath 공개 여부는 package contract gate 후보로 남긴다.
43
+
32
44
  ## 확인 완료 도구 목록
33
45
 
46
+ 아래 `/modal`, `/weather` 같은 표기는 API inventory를 묶는 카테고리 label이다. 실제 consumer import path는 현재 root namespace import를 기준으로 한다.
47
+
34
48
  - `/modal`
35
49
  - `Modal.Provider`
36
50
  - `Modal.StackProvider`
@@ -189,15 +203,14 @@ export default nextConfig;
189
203
 
190
204
  ### 2) 스타일 전역 주입
191
205
 
192
- 템플릿은 primitives 위에 구성되므로, 루트에서 `@uniai-fe/uds-templates/styles`(Sass) 또는 `@uniai-fe/uds-templates/css`(번들 CSS) 하나를 **한 번만** import 하면 foundation 토큰, primitives, templates 스타일이 모두 초기화됩니다.
206
+ templates style entry는 templates styles만 제공하며 foundation/primitives style을 다시 로드하지 않습니다. 서비스 앱은 app root 또는 global stylesheet에서 `foundation -> primitives -> templates` 순서로 style entry를 명시적으로 로드해야 합니다.
193
207
 
194
- ```scss
195
- /* app/globals.scss (Sass 사용 프로젝트) */
196
- @use "@uniai-fe/uds-templates/styles";
197
- ```
208
+ CSS-only 소비:
198
209
 
199
210
  ```ts
200
- // app/layout.tsx (Sass 비사용 프로젝트)
211
+ // app/layout.tsx
212
+ import "@uniai-fe/uds-foundation/css";
213
+ import "@uniai-fe/uds-primitives/css";
201
214
  import "@uniai-fe/uds-templates/css";
202
215
 
203
216
  export default function RootLayout(props: { children: React.ReactNode }) {
@@ -209,23 +222,40 @@ export default function RootLayout(props: { children: React.ReactNode }) {
209
222
  }
210
223
  ```
211
224
 
225
+ Sass 소비:
226
+
227
+ ```scss
228
+ /* app/globals.scss */
229
+ @use "@uniai-fe/uds-foundation/scss";
230
+ @use "@uniai-fe/uds-primitives/styles";
231
+ @use "@uniai-fe/uds-templates/styles";
232
+ ```
233
+
212
234
  ## 간단 사용 예시
213
235
 
236
+ 아래는 root namespace import와 prop shape를 보여주는 개념 예시다. App Router server page에서 직접 event handler를 넘기지 말고, client wrapper 또는 서비스 앱 로그인 컨테이너에서 조합한다.
237
+
214
238
  ```tsx
215
- // app/(auth)/login/page.tsx
216
- import { AuthLoginTemplate } from "@uniai-fe/uds-templates/auth";
239
+ "use client";
240
+
241
+ import { Auth } from "@uniai-fe/uds-templates";
217
242
 
218
- export default function LoginPage() {
243
+ export function LoginTemplateExample() {
219
244
  return (
220
- <AuthLoginTemplate
221
- text={{
222
- title: "로그인",
223
- description: "서비스 이용을 위해 로그인해 주세요.",
245
+ <Auth.Login.Container
246
+ header={<img src="/logo.svg" alt="서비스 로고" />}
247
+ fieldOptions={{
248
+ onLogin: async (values) => {
249
+ // 실제 로그인 API 호출은 서비스 앱에서 구현
250
+ // await login(values);
251
+ },
224
252
  }}
225
- logoSlot={<img src="/logo.svg" alt="서비스 로고" />}
226
- onSubmit={async (values) => {
227
- // 실제 로그인 API 호출은 서비스 앱에서 구현
228
- // await login(values);
253
+ linkOptions={{
254
+ find: {
255
+ id: "/auth/find-id",
256
+ password: "/auth/find-password",
257
+ },
258
+ signup: "/auth/signup",
229
259
  }}
230
260
  />
231
261
  );
@@ -235,7 +265,7 @@ export default function LoginPage() {
235
265
  > 위 예시는 개념을 설명하기 위한 형태이며,
236
266
  > 실제 props 구조/이름은 `CONTEXT-AUTH.md`에서 확정·관리합니다.
237
267
 
238
- > modules 레포 내부(Storybook 등)에서는 개발 편의를 위해 `@uniai-fe/uds-templates/styles` 엔트리를 import하지만, 외부 서비스/패키지는 `@uniai-fe/uds-templates/css` 엔트리만 사용해야 한다.
268
+ > modules 레포 내부 Storybook local render setup으로 `@uniai-fe/uds-foundation/css` `@uniai-fe/uds-primitives/styles` `@uniai-fe/uds-templates/styles`를 사용한다. 설정은 Storybook 렌더링 검증용이며, 외부 consumer setup은 위 CSS-only/Sass 계약을 따른다.
239
269
 
240
270
  ### 최근 업데이트
241
271
 
@@ -245,9 +275,10 @@ export default function LoginPage() {
245
275
 
246
276
  ### 토큰 스코프 & ThemeProvider
247
277
 
248
- - templates SCSS모든 디자인 토큰을 `:root`에 선언하고, 빌드 시 `scripts/merge-theme-root.mjs`가 토큰 블록을 단일 `:root { ... }`로 합쳐 중복 선언을 제거합니다.
249
- - 서비스 앱은 foundation ThemeProvider가 주입하는 `.uds-theme-root`를 루트에 유지해야 하며, CSS import 순서는 `@uniai-fe/uds-foundation/css` → `@uniai-fe/uds-primitives/css` → `@uniai-fe/uds-templates/css` 입니다.
250
- - modules 레포(Storybook 등)는 SCSS 원본을 직접 사용하지만, 외부 프로젝트는 CSS 엔트리만 import한다는 규칙을 README/CODEX-RULES에 명시하고 있습니다.
278
+ - templates CSS/stylestemplates module styles만 제공하며 upstream foundation/primitives styles를 초기화하지 않습니다.
279
+ - 서비스 앱은 foundation ThemeProvider가 주입하는 `.uds-theme-root`를 루트에 유지할 있지만, ThemeProvider는 CSS import하지 않습니다.
280
+ - CSS-only 소비자는 `@uniai-fe/uds-foundation/css` `@uniai-fe/uds-primitives/css` `@uniai-fe/uds-templates/css` 순서를 사용합니다.
281
+ - Sass 소비자는 `@uniai-fe/uds-foundation/scss` → `@uniai-fe/uds-primitives/styles` → `@uniai-fe/uds-templates/styles` 순서를 사용합니다.
251
282
 
252
283
  ## Modal 모듈 사용법
253
284
 
@@ -260,8 +291,10 @@ ui-legacy에서 사용하던 모달 스택/옵션을 templates 레이어로 옮
260
291
 
261
292
  ```tsx
262
293
  // app/layout.tsx
263
- import "@uniai-fe/uds-templates/styles";
264
- import { Modal } from "@uniai-fe/uds-templates/modal";
294
+ import "@uniai-fe/uds-foundation/css";
295
+ import "@uniai-fe/uds-primitives/css";
296
+ import "@uniai-fe/uds-templates/css";
297
+ import { Modal } from "@uniai-fe/uds-templates";
265
298
 
266
299
  export default function RootLayout({
267
300
  children,
@@ -284,7 +317,7 @@ export default function RootLayout({
284
317
 
285
318
  ```tsx
286
319
  // 예: Alert/Confirm 호출
287
- import { Modal } from "@uniai-fe/uds-templates/modal";
320
+ import { Modal } from "@uniai-fe/uds-templates";
288
321
 
289
322
  export function ExampleActions() {
290
323
  const { newModal } = Modal.useModal();
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "sideEffects": [
8
- "./src/**/*.scss"
8
+ "./src/**/*.scss",
9
+ "./dist/styles.css"
9
10
  ],
10
11
  "license": "MIT",
11
12
  "homepage": "https://www.uniai.co.kr/",
@@ -70,9 +71,9 @@
70
71
  },
71
72
  "devDependencies": {
72
73
  "@svgr/webpack": "^8.1.0",
73
- "@tanstack/react-query": "^5.100.10",
74
+ "@tanstack/react-query": "^5.100.11",
74
75
  "@types/node": "^24.12.3",
75
- "@types/react": "^19.2.14",
76
+ "@types/react": "^19.2.15",
76
77
  "@types/react-dom": "^19.2.3",
77
78
  "@uniai-fe/eslint-config": "workspace:*",
78
79
  "@uniai-fe/next-devkit": "workspace:*",
@@ -89,7 +90,7 @@
89
90
  "jotai": "^2.20.0",
90
91
  "next": "^15.5.18",
91
92
  "prettier": "^3.8.3",
92
- "react-hook-form": "^7.75.0",
93
+ "react-hook-form": "^7.76.0",
93
94
  "sass": "^1.99.0",
94
95
  "typescript": "5.9.3"
95
96
  }
@@ -7,6 +7,9 @@ import type {
7
7
  API_Res_CctvRtcToken,
8
8
  } from "../types";
9
9
 
10
+ export const CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
11
+ export const CCTV_RTC_TOKEN_GC_TIME_MS = 60 * 60 * 1000;
12
+
10
13
  export const getClientCctvCompanyList = async ({
11
14
  url,
12
15
  }: {
@@ -54,8 +57,8 @@ export const useQueryCctvRtcToken = ({
54
57
  queryKey: ["cctv_rtc_token", username, company_id, cam_id, url],
55
58
  queryFn: () => postCctvRtcToken({ company_id, cam_id, username, url }),
56
59
  enabled: Boolean(username && company_id && cam_id),
57
- staleTime: Infinity,
58
- gcTime: Infinity,
60
+ staleTime: CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
61
+ gcTime: CCTV_RTC_TOKEN_GC_TIME_MS,
59
62
  refetchOnMount: false,
60
63
  refetchOnReconnect: false,
61
64
  refetchOnWindowFocus: false,
@@ -224,16 +224,6 @@ export async function getServerCctvToken({
224
224
  token: "",
225
225
  };
226
226
 
227
- const BODY_PRESET: API_Req_CctvRtcTokenOrigin = {
228
- site: "company_id",
229
- username: "harim",
230
- password: "harim",
231
- action: "read",
232
- path: "cam_id",
233
- ttl_seconds: 3600,
234
- kid: "streaming-auth-1",
235
- };
236
-
237
227
  if (!reqBody || !reqBody.username || !reqBody.company_id || !reqBody.cam_id) {
238
228
  nextAPILog("POST", routeUrl, query_url, { reqBody });
239
229
  return {
@@ -257,11 +247,13 @@ export async function getServerCctvToken({
257
247
 
258
248
  // 토큰 요청 payload 구성
259
249
  const bodyData: API_Req_CctvRtcTokenOrigin = {
260
- ...BODY_PRESET,
261
250
  site,
262
251
  username,
263
252
  password,
253
+ action: "read",
264
254
  path,
255
+ ttl_seconds: 3600,
256
+ kid: "streaming-auth-1",
265
257
  };
266
258
 
267
259
  nextAPILog("POST", routeUrl, query_url, { bodyData });
@@ -5,7 +5,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { useSetAtom } from "jotai";
6
6
 
7
7
  // 토큰 발급 쿼리와 API URL 컨텍스트 훅, 타입, react-hook-form 유틸.
8
- import { useQueryCctvRtcToken } from "../apis/client";
8
+ import {
9
+ CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
10
+ useQueryCctvRtcToken,
11
+ } from "../apis/client";
9
12
  import {
10
13
  useCctvApiUrl,
11
14
  useCctvRtcStreamRegistry,
@@ -20,6 +23,52 @@ import { getIsLive } from "../utils/video-state";
20
23
  import { useFormContext, useWatch } from "react-hook-form";
21
24
 
22
25
  const AUTO_RECONNECT_INTERVAL_MS = 3000;
26
+ const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
27
+
28
+ const decodeBase64Url = (value: string): string | null => {
29
+ try {
30
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
31
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
32
+
33
+ return atob(padded);
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ const getJwtExpiresAt = (token: string): number | null => {
40
+ const payload = token.split(".")[1];
41
+ if (!payload) return null;
42
+
43
+ const decodedPayload = decodeBase64Url(payload);
44
+ if (!decodedPayload) return null;
45
+
46
+ try {
47
+ const parsedPayload = JSON.parse(decodedPayload) as { exp?: unknown };
48
+ const exp = parsedPayload.exp;
49
+
50
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
55
+
56
+ const canUseTokenForNewConnection = ({
57
+ dataUpdatedAt,
58
+ token,
59
+ }: {
60
+ dataUpdatedAt: number;
61
+ token: string;
62
+ }): boolean => {
63
+ const expiresAt = getJwtExpiresAt(token);
64
+ const now = Date.now();
65
+
66
+ if (expiresAt) {
67
+ return now + TOKEN_EXPIRY_SAFETY_MS < expiresAt;
68
+ }
69
+
70
+ return now - dataUpdatedAt <= CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
71
+ };
23
72
 
24
73
  /**
25
74
  * CCTV 영상 스트림을 WebRTC로 연결하는 커스텀 훅.
@@ -60,6 +109,7 @@ export function useCctvRtcStream({
60
109
  const activeStreamKeyRef = useRef<string | null>(null);
61
110
  const activeStreamIdentityKeyRef = useRef<string | null>(null);
62
111
  const lastAutoReconnectAtRef = useRef(0);
112
+ const staleTokenRefreshKeyRef = useRef<string | null>(null);
63
113
 
64
114
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
65
115
  const [connectionState, setConnectionState] =
@@ -95,7 +145,7 @@ export function useCctvRtcStream({
95
145
  return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
96
146
  }, [cam?.cam_rtc, username]);
97
147
 
98
- const streamKey = useMemo(() => {
148
+ const streamKeyCandidate = useMemo(() => {
99
149
  if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
100
150
  return "";
101
151
 
@@ -126,6 +176,65 @@ export function useCctvRtcStream({
126
176
  setHasConnected(false);
127
177
  }, [streamIdentityKey]);
128
178
 
179
+ const hasReusableRegistryStream = useMemo(() => {
180
+ if (!streamKeyCandidate) return false;
181
+
182
+ const snapshot = streamRegistry.getSnapshot(streamKeyCandidate);
183
+
184
+ return Boolean(
185
+ snapshot.stream ||
186
+ snapshot.isStreaming ||
187
+ snapshot.connectionState === "connected",
188
+ );
189
+ }, [streamKeyCandidate, streamRegistry]);
190
+
191
+ const canUseTokenForStream = useMemo(() => {
192
+ if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
193
+ if (activeStreamKeyRef.current === streamKeyCandidate) return true;
194
+ if (hasReusableRegistryStream) return true;
195
+
196
+ return canUseTokenForNewConnection({
197
+ dataUpdatedAt: tokenQuery.dataUpdatedAt,
198
+ token: tokenQuery.data.token,
199
+ });
200
+ }, [
201
+ hasReusableRegistryStream,
202
+ streamKeyCandidate,
203
+ tokenQuery.data?.token,
204
+ tokenQuery.dataUpdatedAt,
205
+ ]);
206
+
207
+ const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
208
+
209
+ useEffect(() => {
210
+ if (!streamKeyCandidate || canUseTokenForStream) {
211
+ staleTokenRefreshKeyRef.current = null;
212
+ return;
213
+ }
214
+
215
+ if (
216
+ !tokenQuery.data?.token ||
217
+ tokenQuery.isFetching ||
218
+ tokenQuery.isError
219
+ ) {
220
+ return;
221
+ }
222
+
223
+ if (staleTokenRefreshKeyRef.current === streamKeyCandidate) {
224
+ return;
225
+ }
226
+
227
+ staleTokenRefreshKeyRef.current = streamKeyCandidate;
228
+ void refetchRtcToken();
229
+ }, [
230
+ canUseTokenForStream,
231
+ refetchRtcToken,
232
+ streamKeyCandidate,
233
+ tokenQuery.data?.token,
234
+ tokenQuery.isError,
235
+ tokenQuery.isFetching,
236
+ ]);
237
+
129
238
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
130
239
  useEffect(() => {
131
240
  const currentVideo = videoRef.current;