@uniai-fe/uds-templates 0.10.4 → 0.11.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/dist/styles.css CHANGED
@@ -93,9 +93,16 @@
93
93
  /* Card layout */
94
94
  --cctv-video-radius: 12px;
95
95
  --cctv-list-gap: var(--spacing-gap-5);
96
- --cctv-list-item-min-width: 320px;
96
+ --cctv-list-item-min-width: 300px;
97
97
  --cctv-overlay-padding-x: var(--spacing-padding-8);
98
98
  --cctv-overlay-padding-y: var(--spacing-padding-6);
99
+ --cctv-viewer-video-radius: 16px;
100
+ --cctv-viewer-video-max-height: 618px;
101
+ --cctv-viewer-overlay-padding-x: var(--spacing-padding-11);
102
+ --cctv-viewer-overlay-padding-y: var(--spacing-padding-9);
103
+ --cctv-card-settings-bg: var(--color-common-99);
104
+ --cctv-card-settings-border: var(--color-border-assistive);
105
+ --cctv-card-height: 240px;
99
106
  --cctv-video-bg: var(--color-surface-heavy);
100
107
  --cctv-overlay-bg: linear-gradient(
101
108
  180deg,
@@ -109,12 +116,20 @@
109
116
  --cctv-live-state-bg-off: var(--color-cool-gray-55);
110
117
  --cctv-live-state-dot-off: var(--color-border-strong);
111
118
  --cctv-live-state-text-off: var(--color-label-disabled);
119
+ --cctv-state-dot-size: var(--spacing-gap-3);
120
+ /* Sensor metric */
121
+ --cctv-metric-bg: rgba(0, 0, 0, 0.55);
122
+ --cctv-metric-text: var(--color-common-99);
123
+ --cctv-metric-dot-normal: var(--color-feedback-success);
124
+ --cctv-metric-dot-attention: var(--color-feedback-new);
125
+ --cctv-metric-dot-unavailable: var(--color-border-strong);
112
126
  /* Error */
113
127
  --cctv-status-text-color: var(--color-common-99);
114
128
  --cctv-error-text-color: var(--color-label-disabled);
115
129
  --cctv-error-icon-color: var(--color-label-disabled);
116
130
  /* Pagination */
117
131
  --cctv-carousel-gap: var(--spacing-gap-5);
132
+ --cctv-pagination-item-width: 170px;
118
133
  --cctv-pagination-move-button-size: 48px;
119
134
  --cctv-pagination-move-button-active: var(--color-cool-gray-20);
120
135
  --cctv-pagination-move-button-disabled: var(--color-cool-gray-85);
@@ -1743,6 +1758,59 @@
1743
1758
  visibility: hidden;
1744
1759
  }
1745
1760
 
1761
+ .cctv-video-container[data-layout="card"] {
1762
+ min-height: 0;
1763
+ max-height: 240px;
1764
+ aspect-ratio: 16/9;
1765
+ }
1766
+
1767
+ .cctv-video-container[data-layout="carousel"] {
1768
+ min-height: 0;
1769
+ aspect-ratio: 16/9;
1770
+ }
1771
+
1772
+ .cctv-video-card {
1773
+ overflow: hidden;
1774
+ border-radius: var(--cctv-video-radius);
1775
+ }
1776
+
1777
+ .cctv-video-card[data-has-settings="true"] {
1778
+ height: var(--cctv-card-height);
1779
+ display: flex;
1780
+ flex-direction: column;
1781
+ }
1782
+ .cctv-video-card[data-has-settings="true"] .cctv-video-card-media {
1783
+ min-height: 0;
1784
+ flex: 1;
1785
+ }
1786
+ .cctv-video-card[data-has-settings="true"]
1787
+ .cctv-video-card-media
1788
+ .cctv-video-container {
1789
+ height: 100%;
1790
+ min-height: 0;
1791
+ aspect-ratio: auto;
1792
+ }
1793
+ .cctv-video-card[data-has-settings="true"]
1794
+ .cctv-video-card-media
1795
+ .cctv-video-container,
1796
+ .cctv-video-card[data-has-settings="true"]
1797
+ .cctv-video-card-media
1798
+ .cctv-video-box
1799
+ video,
1800
+ .cctv-video-card[data-has-settings="true"]
1801
+ .cctv-video-card-media
1802
+ .cctv-video-overlay-container {
1803
+ border-bottom-right-radius: 0;
1804
+ border-bottom-left-radius: 0;
1805
+ }
1806
+
1807
+ .cctv-video-card-settings {
1808
+ flex: 0 0 auto;
1809
+ padding: var(--spacing-padding-6) var(--spacing-padding-8);
1810
+ background: var(--cctv-card-settings-bg);
1811
+ box-shadow: inset 0 0 0 1px var(--cctv-card-settings-border);
1812
+ }
1813
+
1746
1814
  .cctv-video-box {
1747
1815
  width: 100%;
1748
1816
  height: 100%;
@@ -1771,6 +1839,9 @@
1771
1839
  .cctv-video-overlay-header {
1772
1840
  width: 100%;
1773
1841
  height: fit-content;
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ gap: var(--spacing-gap-5);
1774
1845
  }
1775
1846
  .cctv-video-overlay-header.empty {
1776
1847
  height: calc(var(--font-heading-xsmall-size) * 1.5);
@@ -1799,10 +1870,10 @@
1799
1870
  }
1800
1871
 
1801
1872
  .cctv-video-live-state-dot {
1802
- width: 4px;
1803
- height: 4px;
1804
- margin-right: 4px;
1805
- border-radius: 4px;
1873
+ width: var(--cctv-state-dot-size);
1874
+ height: var(--cctv-state-dot-size);
1875
+ margin-right: var(--spacing-gap-3);
1876
+ border-radius: var(--cctv-state-dot-size);
1806
1877
  background: var(--cctv-live-state-dot-off);
1807
1878
  }
1808
1879
 
@@ -1836,7 +1907,7 @@
1836
1907
  }
1837
1908
 
1838
1909
  .cctv-video-header-title {
1839
- font-size: var(--font-heading-large-size);
1910
+ font-size: var(--font-heading-medium-size);
1840
1911
  }
1841
1912
 
1842
1913
  .cctv-video-footer-title {
@@ -1917,12 +1988,52 @@
1917
1988
  margin-top: auto;
1918
1989
  }
1919
1990
 
1991
+ .cctv-video-metrics {
1992
+ display: flex;
1993
+ gap: var(--spacing-gap-4);
1994
+ margin-left: auto;
1995
+ }
1996
+
1997
+ .cctv-video-metric {
1998
+ display: inline-flex;
1999
+ align-items: center;
2000
+ gap: var(--spacing-gap-3);
2001
+ padding: var(--spacing-padding-2) var(--spacing-padding-5);
2002
+ border-radius: 999px;
2003
+ color: var(--cctv-metric-text);
2004
+ background: var(--cctv-metric-bg);
2005
+ font-size: var(--font-label-small-size);
2006
+ line-height: var(--font-label-small-line-height);
2007
+ font-weight: var(--font-label-small-weight);
2008
+ white-space: nowrap;
2009
+ }
2010
+
2011
+ .cctv-video-metric-dot {
2012
+ width: var(--cctv-state-dot-size);
2013
+ height: var(--cctv-state-dot-size);
2014
+ flex: 0 0 var(--cctv-state-dot-size);
2015
+ border-radius: var(--cctv-state-dot-size);
2016
+ background: var(--cctv-metric-dot-normal);
2017
+ }
2018
+
2019
+ .cctv-video-metric[data-state="attention"] .cctv-video-metric-dot {
2020
+ background: var(--cctv-metric-dot-attention);
2021
+ }
2022
+
2023
+ .cctv-video-metric[data-state="unavailable"] .cctv-video-metric-dot {
2024
+ background: var(--cctv-metric-dot-unavailable);
2025
+ }
2026
+
1920
2027
  .cctv-video-open-button {
1921
2028
  flex: 0 0 auto;
1922
2029
  display: flex;
1923
2030
  align-items: center;
1924
2031
  justify-content: center;
1925
2032
  cursor: auto;
2033
+ position: absolute;
2034
+ top: var(--cctv-overlay-padding-y);
2035
+ right: var(--cctv-overlay-padding-x);
2036
+ z-index: 2;
1926
2037
  }
1927
2038
  .cctv-video-open-button svg path {
1928
2039
  stroke: var(--cctv-open-button-color-off);
@@ -1965,29 +2076,21 @@
1965
2076
  }
1966
2077
 
1967
2078
  .cctv-cam-list-item .cctv-video-container {
2079
+ min-height: 0;
1968
2080
  max-height: 240px;
2081
+ aspect-ratio: 16/9;
1969
2082
  }
1970
2083
 
1971
- @container cctv-cam-list (min-width: 652px) {
2084
+ @container cctv-cam-list (min-width: 612px) {
1972
2085
  .cctv-cam-list-track {
1973
2086
  grid-template-columns: repeat(2, minmax(0, 1fr));
1974
2087
  }
1975
2088
  }
1976
- @container cctv-cam-list (min-width: 984px) {
2089
+ @container cctv-cam-list (min-width: 924px) {
1977
2090
  .cctv-cam-list-track {
1978
2091
  grid-template-columns: repeat(3, minmax(0, 1fr));
1979
2092
  }
1980
2093
  }
1981
- @container cctv-cam-list (min-width: 1316px) {
1982
- .cctv-cam-list-track {
1983
- grid-template-columns: repeat(4, minmax(0, 1fr));
1984
- }
1985
- }
1986
- @container cctv-cam-list (min-width: 1648px) {
1987
- .cctv-cam-list-track {
1988
- grid-template-columns: repeat(5, minmax(0, 1fr));
1989
- }
1990
- }
1991
2094
  .cctv-pagination-container {
1992
2095
  width: 100%;
1993
2096
  display: flex;
@@ -2027,7 +2130,7 @@
2027
2130
  }
2028
2131
 
2029
2132
  .cctv-pagination-track > li {
2030
- flex: 0 0 minmax(150px, 1fr);
2133
+ flex: 0 0 var(--cctv-pagination-item-width);
2031
2134
  }
2032
2135
 
2033
2136
  .cctv-pagination-list-item {
@@ -2035,6 +2138,7 @@
2035
2138
  }
2036
2139
 
2037
2140
  .cctv-pagination-list-item .cctv-video-container {
2141
+ min-height: 0;
2038
2142
  max-height: 152px;
2039
2143
  }
2040
2144
 
@@ -2060,12 +2164,81 @@
2060
2164
  background: var(--cctv-pagination-move-button-disabled);
2061
2165
  }
2062
2166
 
2167
+ .cctv-viewer-desktop-container {
2168
+ display: grid;
2169
+ gap: var(--spacing-gap-6);
2170
+ width: min(100%, 1216px);
2171
+ margin: 0 auto;
2172
+ }
2173
+
2174
+ .cctv-viewer-desktop-headline {
2175
+ min-height: 34px;
2176
+ display: flex;
2177
+ align-items: center;
2178
+ justify-content: space-between;
2179
+ gap: var(--spacing-gap-6);
2180
+ }
2181
+ .cctv-viewer-desktop-headline h3 {
2182
+ min-width: 0;
2183
+ margin: 0;
2184
+ overflow: hidden;
2185
+ color: var(--color-label-strong);
2186
+ font-size: var(--font-heading-medium-size);
2187
+ line-height: 1.4;
2188
+ font-weight: 700;
2189
+ text-overflow: ellipsis;
2190
+ white-space: nowrap;
2191
+ }
2192
+
2193
+ .cctv-viewer-desktop-time {
2194
+ display: inline-flex;
2195
+ align-items: center;
2196
+ gap: var(--spacing-gap-3);
2197
+ color: var(--color-label-neutral);
2198
+ font-size: var(--font-label-small-size);
2199
+ line-height: var(--font-label-small-line-height);
2200
+ font-weight: var(--font-label-small-weight);
2201
+ white-space: nowrap;
2202
+ }
2203
+
2063
2204
  .cctv-viewer-desktop-video-container {
2064
- max-height: 500px;
2205
+ --cctv-video-radius: var(--cctv-viewer-video-radius);
2206
+ --cctv-overlay-padding-x: var(--cctv-viewer-overlay-padding-x);
2207
+ --cctv-overlay-padding-y: var(--cctv-viewer-overlay-padding-y);
2208
+ max-height: var(--cctv-viewer-video-max-height);
2209
+ min-height: 0;
2210
+ aspect-ratio: 608/309;
2211
+ }
2212
+
2213
+ .cctv-viewer-desktop-overlay-body {
2214
+ flex-direction: row;
2215
+ justify-content: space-between;
2216
+ }
2217
+
2218
+ .cctv-viewer-desktop-move-button.cctv-pagination-move-button {
2219
+ width: 42px;
2220
+ height: 42px;
2221
+ background: var(--cctv-pagination-move-button-active);
2222
+ }
2223
+
2224
+ .cctv-viewer-desktop-video-container .cctv-video-overlay-footer {
2225
+ justify-content: flex-end;
2226
+ }
2227
+
2228
+ .cctv-visually-hidden {
2229
+ position: absolute;
2230
+ width: 1px;
2231
+ height: 1px;
2232
+ padding: 0;
2233
+ margin: -1px;
2234
+ overflow: hidden;
2235
+ clip: rect(0, 0, 0, 0);
2236
+ white-space: nowrap;
2237
+ border: 0;
2065
2238
  }
2066
2239
 
2067
2240
  .cctv-viewer-desktop-pagination-container {
2068
- margin-top: var(--spacing-gap-8);
2241
+ margin-top: var(--spacing-gap-2);
2069
2242
  }
2070
2243
 
2071
2244
  .service-inquiry-form {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -56,27 +56,27 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@svgr/webpack": "^8.1.0",
59
- "@tanstack/react-query": "^5.101.2",
60
- "@types/node": "^24.13.2",
59
+ "@tanstack/react-query": "^5.101.4",
60
+ "@types/node": "^24.13.3",
61
61
  "@types/react": "^19.2.17",
62
62
  "@types/react-dom": "^19.2.3",
63
- "eslint": "^9.39.4",
63
+ "eslint": "^9.39.5",
64
64
  "jotai": "^2.20.2",
65
65
  "next": "16.2.10",
66
- "prettier": "^3.8.5",
67
- "react-hook-form": "^7.81.0",
68
- "sass": "^1.101.0",
66
+ "prettier": "^3.9.6",
67
+ "react-hook-form": "^7.82.0",
68
+ "sass": "^1.101.3",
69
69
  "typescript": "6.0.3",
70
70
  "@uniai-fe/next-devkit": "0.4.0",
71
- "@uniai-fe/eslint-config": "0.4.1",
72
- "@uniai-fe/tsconfig": "0.2.0",
71
+ "@uniai-fe/uds-primitives": "0.10.2",
72
+ "@uniai-fe/uds-foundation": "0.5.0",
73
+ "@uniai-fe/eslint-config": "0.4.2",
73
74
  "@uniai-fe/react-hooks": "0.3.0",
75
+ "@uniai-fe/tsconfig": "0.2.0",
74
76
  "@uniai-fe/util-api": "0.2.1",
75
- "@uniai-fe/uds-primitives": "0.10.0",
76
77
  "@uniai-fe/util-functions": "0.4.3",
77
78
  "@uniai-fe/util-jotai": "0.3.0",
78
79
  "@uniai-fe/util-rtc": "0.2.1",
79
- "@uniai-fe/uds-foundation": "0.5.0",
80
80
  "@uniai-fe/util-next": "0.5.0"
81
81
  },
82
82
  "scripts": {
@@ -50,15 +50,16 @@ export default function CCTVCamListItem({
50
50
  ref={videoRef}
51
51
  cam={cam}
52
52
  className="cctv-cam-list-video-container"
53
- // list card에서는 live/share 상태를 header에 노출하고 제목은 footer로 내려 보낸다.
53
+ layout="card"
54
+ // list card에서는 live/share 상태와 제목을 header에 함께 노출한다.
54
55
  headerOptions={{
55
56
  activeLiveState: true,
56
- activeTitle: false,
57
+ activeTitle: true,
57
58
  isLive,
58
59
  isShared: cam.is_public === true,
59
60
  title: cam.cam_name,
60
61
  }}
61
- footerOptions={{ activeTitle: true, activeOpenButton: true, cam }}
62
+ footerOptions={{ activeTitle: false, activeOpenButton: true, cam }}
62
63
  // 변경 설명: list item custom overlay는 template seam 하나만 따라간다.
63
64
  renderOverlay={renderOverlay}
64
65
  {...{
@@ -84,8 +84,9 @@ export function CctvPaginationCarouselProvider({
84
84
  const scrollToIndex = useCallback((index: number) => {
85
85
  if (!viewportRef.current || !trackRef.current) return;
86
86
  const panel = trackRef.current.children[index] as HTMLElement | undefined;
87
+ const firstPanel = trackRef.current.children[0] as HTMLElement | undefined;
87
88
  if (!panel) return;
88
- const targetLeft = panel.offsetLeft;
89
+ const targetLeft = panel.offsetLeft - (firstPanel?.offsetLeft ?? 0);
89
90
  const currentLeft = viewportRef.current.scrollLeft;
90
91
  if (Math.abs(currentLeft - targetLeft) < 1) {
91
92
  setCurrentIndex(index);
@@ -136,11 +137,14 @@ export function CctvPaginationCarouselProvider({
136
137
  const { scrollLeft } = viewport;
137
138
  const panels = trackRef.current?.children ?? [];
138
139
  if (!panels.length) return;
140
+ const firstOffset = (panels[0] as HTMLElement).offsetLeft;
139
141
  let closestIndex = 0;
140
142
  let smallestDistance = Number.POSITIVE_INFINITY;
141
143
  for (let i = 0; i < panels.length; i += 1) {
142
144
  const panel = panels[i] as HTMLElement;
143
- const distance = Math.abs(panel.offsetLeft - scrollLeft);
145
+ const distance = Math.abs(
146
+ panel.offsetLeft - firstOffset - scrollLeft,
147
+ );
144
148
  if (distance < smallestDistance) {
145
149
  smallestDistance = distance;
146
150
  closestIndex = i;
@@ -67,6 +67,7 @@ export default function CCTVPaginationListItem({
67
67
  ref={videoRef}
68
68
  cam={cam}
69
69
  className="cctv-pagination-list-video-container"
70
+ layout="carousel"
70
71
  // thumbnail에서는 header live/share 상태만 보이고 footer action은 최소화한다.
71
72
  headerOptions={{
72
73
  activeLiveState: true,
@@ -0,0 +1,30 @@
1
+ import clsx from "clsx";
2
+ import type { CctvVideoCardProps } from "../../types";
3
+
4
+ /**
5
+ * CCTV; service settings를 결합하는 video card
6
+ * @component
7
+ * @param {CctvVideoCardProps} props
8
+ * @param {React.ReactNode} props.children video/media 영역
9
+ * @param {React.ReactNode} [props.settings] media 하단 서비스 설정 영역
10
+ * @param {string} [props.className] 카드 루트 클래스
11
+ * @example
12
+ * <CCTV.Video.Card settings={<Controls />}><CCTV.Video.Template /></CCTV.Video.Card>
13
+ */
14
+ export default function CCTVVideoCard({
15
+ children,
16
+ className,
17
+ settings,
18
+ }: CctvVideoCardProps) {
19
+ return (
20
+ <section
21
+ className={clsx("cctv-video-card", className)}
22
+ data-has-settings={typeof settings !== "undefined" ? "true" : undefined}
23
+ >
24
+ <div className="cctv-video-card-media">{children}</div>
25
+ {typeof settings !== "undefined" ? (
26
+ <div className="cctv-video-card-settings">{settings}</div>
27
+ ) : null}
28
+ </section>
29
+ );
30
+ }
@@ -4,15 +4,18 @@ export default function CCTVVideoContainer({
4
4
  className,
5
5
  children,
6
6
  isError,
7
+ layout,
7
8
  }: {
8
9
  className?: string;
9
10
  children: React.ReactNode;
10
11
  isError?: boolean;
12
+ layout?: "card" | "carousel" | "viewer";
11
13
  }) {
12
14
  return (
13
15
  <div
14
16
  className={clsx("cctv-video-container", className)}
15
17
  data-error={isError ? "true" : undefined}
18
+ data-layout={layout}
16
19
  >
17
20
  {children}
18
21
  </div>
@@ -33,6 +33,7 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
33
33
  footerOptions,
34
34
  isError,
35
35
  isLive,
36
+ layout,
36
37
  overlayMessage,
37
38
  streamStage,
38
39
  canReconnect,
@@ -69,7 +70,11 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
69
70
  : defaultOverlay;
70
71
 
71
72
  return (
72
- <CCTVVideoContainer className={className} isError={isError}>
73
+ <CCTVVideoContainer
74
+ className={className}
75
+ isError={isError}
76
+ layout={layout}
77
+ >
73
78
  <CCTVVideoContents ref={ref} muted />
74
79
  {overlayNode}
75
80
  </CCTVVideoContainer>
@@ -2,10 +2,12 @@ import CCTVVideoContainer from "./Container";
2
2
  import { CCTVVideoOverlay } from "./overlay";
3
3
  import CCTVVideoTemplate from "./Template";
4
4
  import CCTVVideoContents from "./Video";
5
+ import CCTVVideoCard from "./Card";
5
6
 
6
7
  export const CCTVVideo = {
7
8
  Container: CCTVVideoContainer,
8
9
  Contents: CCTVVideoContents,
10
+ Card: CCTVVideoCard,
9
11
  Template: CCTVVideoTemplate,
10
12
  Overlay: CCTVVideoOverlay,
11
13
  };
@@ -5,6 +5,7 @@ import type { CctvVideoOverlayFooterProps } from "../../../../types";
5
5
 
6
6
  export default function CCTVVideoOverlayFooter({
7
7
  className,
8
+ children,
8
9
  activeTitle = true,
9
10
  activeOpenButton,
10
11
  cam,
@@ -23,6 +24,7 @@ export default function CCTVVideoOverlayFooter({
23
24
  {activeOpenButton && cam && (
24
25
  <CCTVVideoOpenButton {...cam} isLive={isLive} />
25
26
  )}
27
+ {children}
26
28
  </footer>
27
29
  );
28
30
  }
@@ -0,0 +1,22 @@
1
+ import type { CctvVideoMetricProps } from "../../../../types";
2
+
3
+ /**
4
+ * CCTV; state dot을 포함한 sensor metric
5
+ * @component
6
+ * @param {CctvVideoMetricProps} props
7
+ * @param {React.ReactNode} props.children 표시할 센서 값
8
+ * @param {"normal" | "attention" | "unavailable"} [props.state="normal"] state dot 표시 상태
9
+ * @example
10
+ * <CCTV.Video.Overlay.Footer.Metric state="attention">온도 38℃</CCTV.Video.Overlay.Footer.Metric>
11
+ */
12
+ export default function CCTVVideoMetric({
13
+ children,
14
+ state = "normal",
15
+ }: CctvVideoMetricProps) {
16
+ return (
17
+ <span className="cctv-video-metric" data-state={state}>
18
+ <span className="cctv-video-metric-dot" aria-hidden="true" />
19
+ <span>{children}</span>
20
+ </span>
21
+ );
22
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * CCTV; sensor metric group
3
+ * @component
4
+ * @param {object} props
5
+ * @param {React.ReactNode} props.children sensor metric 목록
6
+ */
7
+ export default function CCTVVideoMetrics({
8
+ children,
9
+ }: {
10
+ children: React.ReactNode;
11
+ }) {
12
+ return <div className="cctv-video-metrics">{children}</div>;
13
+ }
@@ -21,7 +21,7 @@ export default function CCTVVideoLiveState({
21
21
  {...options}
22
22
  >
23
23
  <figure className="cctv-video-live-state-dot"></figure>
24
- <span>{isShared ? "외부공유중" : "Live"}</span>
24
+ <span>{isShared ? "외부 공유" : "Live"}</span>
25
25
  </Badge>
26
26
  );
27
27
  }
@@ -3,6 +3,8 @@ import CCTVVideoError from "./body/Error";
3
3
  import CCTVVideoOverlayContainer from "./Container";
4
4
  import CCTVVideoOverlayFooter from "./footer/Container";
5
5
  import CCTVVideoOpenButton from "./footer/OpenButton";
6
+ import CCTVVideoMetric from "./footer/Metric";
7
+ import CCTVVideoMetrics from "./footer/Metrics";
6
8
  import CCTVVideoCloseButton from "./header/CloseButton";
7
9
  import CCTVVideoOverlayHeader from "./header/Container";
8
10
  import CCTVVideoLiveState from "./header/LiveState";
@@ -20,5 +22,7 @@ export const CCTVVideoOverlay = {
20
22
  Footer: {
21
23
  Container: CCTVVideoOverlayFooter,
22
24
  OpenButton: CCTVVideoOpenButton,
25
+ Metric: CCTVVideoMetric,
26
+ Metrics: CCTVVideoMetrics,
23
27
  },
24
28
  };
@@ -1,8 +1,12 @@
1
+ "use client";
2
+
1
3
  import clsx from "clsx";
2
4
  import CCTVViewerContainer from "../Container";
3
5
  import CCTVViewerDesktopPagination from "./Pagination";
4
6
  import CCTVViewerDesktopVideo from "./Video";
7
+ import CCTVViewerDesktopHeadline from "./Headline";
5
8
  import type { CctvViewerDesktopContainerProps } from "../../../types";
9
+ import { useCctvContext } from "../../../hooks";
6
10
 
7
11
  /**
8
12
  * CCTV; viewer desktop container
@@ -21,8 +25,27 @@ export default function CCTVViewerDesktopContainer({
21
25
  children,
22
26
  selectedCam,
23
27
  camList,
28
+ metrics,
29
+ onNext,
30
+ onPrev,
24
31
  renderOverlay,
32
+ time,
25
33
  }: CctvViewerDesktopContainerProps) {
34
+ const context = useCctvContext();
35
+ const resolvedCam = selectedCam ?? context.selectedCam;
36
+ const resolvedList = camList ?? context.cams;
37
+ const currentIndex = resolvedList.findIndex(
38
+ cam => cam.cam_id === resolvedCam?.cam_id,
39
+ );
40
+ const resolvedPrev =
41
+ onPrev ??
42
+ (currentIndex > 0 ? resolvedList[currentIndex - 1]?.onSelect : undefined);
43
+ const resolvedNext =
44
+ onNext ??
45
+ (currentIndex >= 0 && currentIndex < resolvedList.length - 1
46
+ ? resolvedList[currentIndex + 1]?.onSelect
47
+ : undefined);
48
+
26
49
  return (
27
50
  <CCTVViewerContainer
28
51
  className={clsx("cctv-viewer-desktop-container", className)}
@@ -31,15 +54,23 @@ export default function CCTVViewerDesktopContainer({
31
54
  children
32
55
  ) : (
33
56
  <>
57
+ <CCTVViewerDesktopHeadline
58
+ title={resolvedCam?.cam_name ?? "-"}
59
+ time={time}
60
+ />
34
61
  <CCTVViewerDesktopVideo
35
- {...(typeof selectedCam !== "undefined"
36
- ? { cam: selectedCam }
62
+ {...(typeof resolvedCam !== "undefined"
63
+ ? { cam: resolvedCam }
37
64
  : {})}
38
- // 변경 설명: container에서 받은 seam을 main viewer video로 전달한다.
65
+ metrics={metrics}
66
+ onNext={resolvedNext}
67
+ onPrev={resolvedPrev}
39
68
  renderOverlay={renderOverlay}
40
69
  />
41
70
  <CCTVViewerDesktopPagination
42
- {...(typeof camList !== "undefined" ? { list: camList } : {})}
71
+ {...(typeof resolvedList !== "undefined"
72
+ ? { list: resolvedList }
73
+ : {})}
43
74
  />
44
75
  </>
45
76
  )}
@@ -0,0 +1,28 @@
1
+ import ClockIcon from "../../../img/time.svg";
2
+
3
+ /**
4
+ * CCTV; desktop viewer headline
5
+ * @component
6
+ * @param {object} props
7
+ * @param {React.ReactNode} props.title 선택 카메라 제목
8
+ * @param {React.ReactNode} [props.time] 확인 시각
9
+ */
10
+ export default function CCTVViewerDesktopHeadline({
11
+ title,
12
+ time,
13
+ }: {
14
+ title: React.ReactNode;
15
+ time?: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <header className="cctv-viewer-desktop-headline">
19
+ <h3>{title}</h3>
20
+ {typeof time !== "undefined" ? (
21
+ <time className="cctv-viewer-desktop-time">
22
+ <ClockIcon width={16} height={16} aria-hidden="true" />
23
+ <span>{time}</span>
24
+ </time>
25
+ ) : null}
26
+ </header>
27
+ );
28
+ }
@@ -0,0 +1,60 @@
1
+ import CCTVPaginationBaseButton from "../../pagination/buttons/Base";
2
+ import CCTVVideoOverlayContainer from "../../video/overlay/Container";
3
+ import CCTVVideoOverlayBody from "../../video/overlay/body/Container";
4
+ import CCTVVideoOverlayFooter from "../../video/overlay/footer/Container";
5
+ import CCTVVideoOverlayHeader from "../../video/overlay/header/Container";
6
+
7
+ /**
8
+ * CCTV; desktop viewer video overlay preset
9
+ * @component
10
+ * @param {object} props
11
+ * @param {boolean} [props.isLive] 영상 live 상태
12
+ * @param {boolean} [props.isShared] 외부 공유 상태
13
+ * @param {React.ReactNode} [props.metrics] sensor metric 영역
14
+ * @param {() => void} [props.onPrev] 이전 카메라 이동 핸들러
15
+ * @param {() => void} [props.onNext] 다음 카메라 이동 핸들러
16
+ */
17
+ export default function CCTVViewerDesktopOverlay({
18
+ isLive,
19
+ isShared,
20
+ metrics,
21
+ onNext,
22
+ onPrev,
23
+ }: {
24
+ isLive?: boolean;
25
+ isShared?: boolean;
26
+ metrics?: React.ReactNode;
27
+ onPrev?: () => void;
28
+ onNext?: () => void;
29
+ }) {
30
+ return (
31
+ <CCTVVideoOverlayContainer>
32
+ <CCTVVideoOverlayHeader
33
+ activeLiveState
34
+ isLive={isLive}
35
+ isShared={isShared}
36
+ />
37
+ <CCTVVideoOverlayBody className="cctv-viewer-desktop-overlay-body">
38
+ <CCTVPaginationBaseButton
39
+ className="cctv-viewer-desktop-move-button"
40
+ direction="prev"
41
+ disabled={!onPrev}
42
+ onMove={onPrev ?? (() => undefined)}
43
+ >
44
+ <span className="cctv-visually-hidden">이전 CCTV</span>
45
+ </CCTVPaginationBaseButton>
46
+ <CCTVPaginationBaseButton
47
+ className="cctv-viewer-desktop-move-button"
48
+ direction="next"
49
+ disabled={!onNext}
50
+ onMove={onNext ?? (() => undefined)}
51
+ >
52
+ <span className="cctv-visually-hidden">다음 CCTV</span>
53
+ </CCTVPaginationBaseButton>
54
+ </CCTVVideoOverlayBody>
55
+ <CCTVVideoOverlayFooter activeTitle={false}>
56
+ {metrics}
57
+ </CCTVVideoOverlayFooter>
58
+ </CCTVVideoOverlayContainer>
59
+ );
60
+ }
@@ -4,6 +4,7 @@ import { useMemo } from "react";
4
4
 
5
5
  import { useCctvContext, useCctvRtcStream } from "../../../hooks";
6
6
  import type { CctvViewerDesktopVideoProps } from "../../../types";
7
+ import CCTVViewerDesktopOverlay from "./Overlay";
7
8
  import CCTVVideoTemplate from "../../video/Template";
8
9
  import {
9
10
  getOverlayMessage,
@@ -22,6 +23,9 @@ import {
22
23
  */
23
24
  export default function CCTVViewerDesktopVideo({
24
25
  cam: propsCam,
26
+ metrics,
27
+ onNext,
28
+ onPrev,
25
29
  renderOverlay,
26
30
  }: CctvViewerDesktopVideoProps) {
27
31
  const { selectedCam, isFetching } = useCctvContext();
@@ -46,17 +50,25 @@ export default function CCTVViewerDesktopVideo({
46
50
  ref={videoRef}
47
51
  cam={cam}
48
52
  className="cctv-viewer-desktop-video-container"
53
+ layout="viewer"
49
54
  headerOptions={{
50
55
  activeLiveState: true,
51
- activeTitle: true,
52
- activeCloseButton: true,
53
56
  isLive,
54
57
  isShared: cam?.is_public === true,
55
- title: cam?.cam_name,
56
58
  }}
57
59
  footerOptions={{ activeTitle: false }}
58
- // 변경 설명: viewer video는 service custom overlay를 직접 수용하는 첫 public entry다.
59
- renderOverlay={renderOverlay}
60
+ renderOverlay={
61
+ renderOverlay ??
62
+ (() => (
63
+ <CCTVViewerDesktopOverlay
64
+ isLive={isLive}
65
+ isShared={cam?.is_public === true}
66
+ metrics={metrics}
67
+ onNext={onNext}
68
+ onPrev={onPrev}
69
+ />
70
+ ))
71
+ }
60
72
  {...{
61
73
  isError,
62
74
  overlayMessage,
@@ -1,12 +1,16 @@
1
1
  import CCTVViewerDesktopContainer from "./desktop/Container";
2
2
  import CCTVViewerDesktopPagination from "./desktop/Pagination";
3
3
  import CCTVViewerDesktopVideo from "./desktop/Video";
4
+ import CCTVViewerDesktopHeadline from "./desktop/Headline";
5
+ import CCTVViewerDesktopOverlay from "./desktop/Overlay";
4
6
 
5
7
  export const CCTVViewer = {
6
8
  Desktop: {
7
9
  Container: CCTVViewerDesktopContainer,
8
10
  Video: CCTVViewerDesktopVideo,
9
11
  Pagination: CCTVViewerDesktopPagination,
12
+ Headline: CCTVViewerDesktopHeadline,
13
+ Overlay: CCTVViewerDesktopOverlay,
10
14
  },
11
15
  Mobile: {},
12
16
  };
@@ -0,0 +1,4 @@
1
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <circle cx="8" cy="8" r="6.5" fill="currentColor"/>
3
+ <path d="M8 4.5V8L10.5 9.5" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
4
+ </svg>
@@ -32,30 +32,20 @@
32
32
  }
33
33
 
34
34
  .cctv-cam-list-item .cctv-video-container {
35
+ min-height: 0;
35
36
  max-height: 240px;
37
+ aspect-ratio: 16 / 9;
36
38
  }
37
39
 
38
- // 4:3 card ratio는 video container가 책임지고, list는 320px card + 12px gap 기준으로 수만 조정한다.
39
- @container cctv-cam-list (min-width: 652px) {
40
+ // CCTV 목록은 300px card + 12px gap 기준으로 최대 3열까지만 확장한다.
41
+ @container cctv-cam-list (min-width: 612px) {
40
42
  .cctv-cam-list-track {
41
43
  grid-template-columns: repeat(2, minmax(0, 1fr));
42
44
  }
43
45
  }
44
46
 
45
- @container cctv-cam-list (min-width: 984px) {
47
+ @container cctv-cam-list (min-width: 924px) {
46
48
  .cctv-cam-list-track {
47
49
  grid-template-columns: repeat(3, minmax(0, 1fr));
48
50
  }
49
51
  }
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
- }
@@ -40,7 +40,7 @@
40
40
 
41
41
  .cctv-pagination-track > li {
42
42
  // panel 너비를 고정해 viewport 이동 시 균등하게 보이도록 한다.
43
- flex: 0 0 minmax(150px, 1fr);
43
+ flex: 0 0 var(--cctv-pagination-item-width);
44
44
  }
45
45
 
46
46
  .cctv-pagination-list-item {
@@ -48,6 +48,7 @@
48
48
  }
49
49
 
50
50
  .cctv-pagination-list-item .cctv-video-container {
51
+ min-height: 0;
51
52
  max-height: 152px;
52
53
  }
53
54
 
@@ -2,11 +2,19 @@
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
+ --cctv-list-item-min-width: 300px;
6
6
 
7
7
  --cctv-overlay-padding-x: var(--spacing-padding-8);
8
8
  --cctv-overlay-padding-y: var(--spacing-padding-6);
9
9
 
10
+ --cctv-viewer-video-radius: 16px;
11
+ --cctv-viewer-video-max-height: 618px;
12
+ --cctv-viewer-overlay-padding-x: var(--spacing-padding-11);
13
+ --cctv-viewer-overlay-padding-y: var(--spacing-padding-9);
14
+ --cctv-card-settings-bg: var(--color-common-99);
15
+ --cctv-card-settings-border: var(--color-border-assistive);
16
+ --cctv-card-height: 240px;
17
+
10
18
  --cctv-video-bg: var(--color-surface-heavy);
11
19
  --cctv-overlay-bg: linear-gradient(
12
20
  180deg,
@@ -21,6 +29,14 @@
21
29
  --cctv-live-state-bg-off: var(--color-cool-gray-55);
22
30
  --cctv-live-state-dot-off: var(--color-border-strong);
23
31
  --cctv-live-state-text-off: var(--color-label-disabled);
32
+ --cctv-state-dot-size: var(--spacing-gap-3);
33
+
34
+ /* Sensor metric */
35
+ --cctv-metric-bg: rgba(0, 0, 0, 0.55);
36
+ --cctv-metric-text: var(--color-common-99);
37
+ --cctv-metric-dot-normal: var(--color-feedback-success);
38
+ --cctv-metric-dot-attention: var(--color-feedback-new);
39
+ --cctv-metric-dot-unavailable: var(--color-border-strong);
24
40
 
25
41
  /* Error */
26
42
  --cctv-status-text-color: var(--color-common-99);
@@ -29,6 +45,7 @@
29
45
 
30
46
  /* Pagination */
31
47
  --cctv-carousel-gap: var(--spacing-gap-5);
48
+ --cctv-pagination-item-width: 170px;
32
49
  --cctv-pagination-move-button-size: 48px;
33
50
  --cctv-pagination-move-button-active: var(--color-cool-gray-20);
34
51
  --cctv-pagination-move-button-disabled: var(--color-cool-gray-85);
@@ -16,6 +16,53 @@
16
16
  }
17
17
  }
18
18
 
19
+ .cctv-video-container[data-layout="card"] {
20
+ min-height: 0;
21
+ max-height: 240px;
22
+ aspect-ratio: 16 / 9;
23
+ }
24
+
25
+ .cctv-video-container[data-layout="carousel"] {
26
+ min-height: 0;
27
+ aspect-ratio: 16 / 9;
28
+ }
29
+
30
+ .cctv-video-card {
31
+ overflow: hidden;
32
+ border-radius: var(--cctv-video-radius);
33
+ }
34
+
35
+ .cctv-video-card[data-has-settings="true"] {
36
+ height: var(--cctv-card-height);
37
+ display: flex;
38
+ flex-direction: column;
39
+
40
+ .cctv-video-card-media {
41
+ min-height: 0;
42
+ flex: 1;
43
+ }
44
+
45
+ .cctv-video-card-media .cctv-video-container {
46
+ height: 100%;
47
+ min-height: 0;
48
+ aspect-ratio: auto;
49
+ }
50
+
51
+ .cctv-video-card-media .cctv-video-container,
52
+ .cctv-video-card-media .cctv-video-box video,
53
+ .cctv-video-card-media .cctv-video-overlay-container {
54
+ border-bottom-right-radius: 0;
55
+ border-bottom-left-radius: 0;
56
+ }
57
+ }
58
+
59
+ .cctv-video-card-settings {
60
+ flex: 0 0 auto;
61
+ padding: var(--spacing-padding-6) var(--spacing-padding-8);
62
+ background: var(--cctv-card-settings-bg);
63
+ box-shadow: inset 0 0 0 1px var(--cctv-card-settings-border);
64
+ }
65
+
19
66
  .cctv-video-box {
20
67
  width: 100%;
21
68
  height: 100%;
@@ -46,6 +93,9 @@
46
93
  .cctv-video-overlay-header {
47
94
  width: 100%;
48
95
  height: fit-content;
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: var(--spacing-gap-5);
49
99
  &.empty {
50
100
  height: calc(var(--font-heading-xsmall-size) * 1.5);
51
101
  }
@@ -75,10 +125,10 @@
75
125
  }
76
126
  }
77
127
  .cctv-video-live-state-dot {
78
- width: 4px;
79
- height: 4px;
80
- margin-right: 4px;
81
- border-radius: 4px;
128
+ width: var(--cctv-state-dot-size);
129
+ height: var(--cctv-state-dot-size);
130
+ margin-right: var(--spacing-gap-3);
131
+ border-radius: var(--cctv-state-dot-size);
82
132
  background: var(--cctv-live-state-dot-off);
83
133
  }
84
134
 
@@ -113,7 +163,7 @@
113
163
  }
114
164
  }
115
165
  .cctv-video-header-title {
116
- font-size: var(--font-heading-large-size);
166
+ font-size: var(--font-heading-medium-size);
117
167
  }
118
168
  .cctv-video-footer-title {
119
169
  flex: 1 1 auto;
@@ -200,12 +250,52 @@
200
250
  margin-top: auto;
201
251
  }
202
252
 
253
+ .cctv-video-metrics {
254
+ display: flex;
255
+ gap: var(--spacing-gap-4);
256
+ margin-left: auto;
257
+ }
258
+
259
+ .cctv-video-metric {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ gap: var(--spacing-gap-3);
263
+ padding: var(--spacing-padding-2) var(--spacing-padding-5);
264
+ border-radius: 999px;
265
+ color: var(--cctv-metric-text);
266
+ background: var(--cctv-metric-bg);
267
+ font-size: var(--font-label-small-size);
268
+ line-height: var(--font-label-small-line-height);
269
+ font-weight: var(--font-label-small-weight);
270
+ white-space: nowrap;
271
+ }
272
+
273
+ .cctv-video-metric-dot {
274
+ width: var(--cctv-state-dot-size);
275
+ height: var(--cctv-state-dot-size);
276
+ flex: 0 0 var(--cctv-state-dot-size);
277
+ border-radius: var(--cctv-state-dot-size);
278
+ background: var(--cctv-metric-dot-normal);
279
+ }
280
+
281
+ .cctv-video-metric[data-state="attention"] .cctv-video-metric-dot {
282
+ background: var(--cctv-metric-dot-attention);
283
+ }
284
+
285
+ .cctv-video-metric[data-state="unavailable"] .cctv-video-metric-dot {
286
+ background: var(--cctv-metric-dot-unavailable);
287
+ }
288
+
203
289
  .cctv-video-open-button {
204
290
  flex: 0 0 auto;
205
291
  display: flex;
206
292
  align-items: center;
207
293
  justify-content: center;
208
294
  cursor: auto;
295
+ position: absolute;
296
+ top: var(--cctv-overlay-padding-y);
297
+ right: var(--cctv-overlay-padding-x);
298
+ z-index: 2;
209
299
  svg path {
210
300
  stroke: var(--cctv-open-button-color-off);
211
301
  }
@@ -1,7 +1,78 @@
1
+ .cctv-viewer-desktop-container {
2
+ display: grid;
3
+ gap: var(--spacing-gap-6);
4
+ width: min(100%, 1216px);
5
+ margin: 0 auto;
6
+ }
7
+
8
+ .cctv-viewer-desktop-headline {
9
+ min-height: 34px;
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: var(--spacing-gap-6);
14
+
15
+ h3 {
16
+ min-width: 0;
17
+ margin: 0;
18
+ overflow: hidden;
19
+ color: var(--color-label-strong);
20
+ font-size: var(--font-heading-medium-size);
21
+ line-height: 1.4;
22
+ font-weight: 700;
23
+ text-overflow: ellipsis;
24
+ white-space: nowrap;
25
+ }
26
+ }
27
+
28
+ .cctv-viewer-desktop-time {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: var(--spacing-gap-3);
32
+ color: var(--color-label-neutral);
33
+ font-size: var(--font-label-small-size);
34
+ line-height: var(--font-label-small-line-height);
35
+ font-weight: var(--font-label-small-weight);
36
+ white-space: nowrap;
37
+ }
38
+
1
39
  .cctv-viewer-desktop-video-container {
2
- max-height: 500px;
40
+ --cctv-video-radius: var(--cctv-viewer-video-radius);
41
+ --cctv-overlay-padding-x: var(--cctv-viewer-overlay-padding-x);
42
+ --cctv-overlay-padding-y: var(--cctv-viewer-overlay-padding-y);
43
+
44
+ max-height: var(--cctv-viewer-video-max-height);
45
+ min-height: 0;
46
+ aspect-ratio: 608 / 309;
47
+ }
48
+
49
+ .cctv-viewer-desktop-overlay-body {
50
+ flex-direction: row;
51
+ justify-content: space-between;
52
+ }
53
+
54
+ .cctv-viewer-desktop-move-button.cctv-pagination-move-button {
55
+ width: 42px;
56
+ height: 42px;
57
+ background: var(--cctv-pagination-move-button-active);
58
+ }
59
+
60
+ .cctv-viewer-desktop-video-container .cctv-video-overlay-footer {
61
+ justify-content: flex-end;
62
+ }
63
+
64
+ .cctv-visually-hidden {
65
+ position: absolute;
66
+ width: 1px;
67
+ height: 1px;
68
+ padding: 0;
69
+ margin: -1px;
70
+ overflow: hidden;
71
+ clip: rect(0, 0, 0, 0);
72
+ white-space: nowrap;
73
+ border: 0;
3
74
  }
4
75
 
5
76
  .cctv-viewer-desktop-pagination-container {
6
- margin-top: var(--spacing-gap-8);
77
+ margin-top: var(--spacing-gap-2);
7
78
  }
@@ -112,6 +112,10 @@ export interface CctvVideoOverlayHeaderProps {
112
112
  */
113
113
  export interface CctvVideoOverlayFooterProps {
114
114
  className?: string;
115
+ /**
116
+ * 제목/버튼과 함께 배치할 추가 콘텐츠
117
+ */
118
+ children?: React.ReactNode;
115
119
  /**
116
120
  * 하단 타이틀 노출 여부
117
121
  */
@@ -130,6 +134,48 @@ export interface CctvVideoOverlayFooterProps {
130
134
  isLive?: boolean;
131
135
  }
132
136
 
137
+ /**
138
+ * CCTV; sensor metric 표시 상태
139
+ */
140
+ export type CctvVideoMetricState = "normal" | "attention" | "unavailable";
141
+
142
+ /**
143
+ * CCTV; sensor metric props
144
+ * @property {React.ReactNode} children 표시할 센서 값
145
+ * @property {CctvVideoMetricState} [state] state dot 표시 상태
146
+ */
147
+ export interface CctvVideoMetricProps {
148
+ /**
149
+ * 표시할 센서 값
150
+ */
151
+ children: React.ReactNode;
152
+ /**
153
+ * state dot 표시 상태
154
+ */
155
+ state?: CctvVideoMetricState;
156
+ }
157
+
158
+ /**
159
+ * CCTV; Video Card props
160
+ * @property {React.ReactNode} children video/media 영역
161
+ * @property {React.ReactNode} [settings] media 하단 서비스 설정 영역
162
+ * @property {string} [className] 카드 루트 클래스
163
+ */
164
+ export interface CctvVideoCardProps {
165
+ /**
166
+ * video/media 영역
167
+ */
168
+ children: React.ReactNode;
169
+ /**
170
+ * media 하단 서비스 설정 영역
171
+ */
172
+ settings?: React.ReactNode;
173
+ /**
174
+ * 카드 루트 클래스
175
+ */
176
+ className?: string;
177
+ }
178
+
133
179
  /**
134
180
  * CCTV; 비디오 상태 관련 props
135
181
  * @property {boolean} [isError] 에러 상태 여부
@@ -169,6 +215,7 @@ export interface CctvVideoStateProps {
169
215
  /**
170
216
  * CCTV; Video Template props
171
217
  * @property {string} [className] 루트 컨테이너에 적용할 클래스
218
+ * @property {"card" | "carousel" | "viewer"} [layout] 공용 video layout preset
172
219
  * @property {CctvVideoOverlayHeaderProps} [headerOptions] 헤더 구성 옵션
173
220
  * @property {CctvVideoOverlayFooterProps} [footerOptions] 푸터 구성 옵션
174
221
  * @property {boolean} [isError] 에러 상태 여부
@@ -183,6 +230,10 @@ export interface CctvVideoTemplateProps extends CctvVideoStateProps {
183
230
  */
184
231
  cam?: CctvCompanyCameraData;
185
232
  className?: string;
233
+ /**
234
+ * 공용 video layout preset
235
+ */
236
+ layout?: "card" | "carousel" | "viewer";
186
237
  /**
187
238
  * <CCtvVideoOverlayHeader /> 속성
188
239
  */
@@ -262,6 +313,18 @@ export interface CctvViewerDesktopVideoProps {
262
313
  * custom overlay 렌더 함수
263
314
  */
264
315
  renderOverlay?: CctvVideoRenderOverlay;
316
+ /**
317
+ * viewer 하단 sensor metric 영역
318
+ */
319
+ metrics?: React.ReactNode;
320
+ /**
321
+ * 이전 카메라 이동 핸들러
322
+ */
323
+ onPrev?: () => void;
324
+ /**
325
+ * 다음 카메라 이동 핸들러
326
+ */
327
+ onNext?: () => void;
265
328
  }
266
329
 
267
330
  /**
@@ -271,6 +334,10 @@ export interface CctvViewerDesktopVideoProps {
271
334
  * @property {CctvCompanyCameraData} [selectedCam] 외부에서 직접 주입하는 선택 카메라 데이터
272
335
  * @property {CctvCompanyCameraData[]} [camList] 외부에서 직접 주입하는 카메라 리스트
273
336
  * @property {CctvVideoRenderOverlay} [renderOverlay] viewer video용 custom overlay 렌더 함수
337
+ * @property {React.ReactNode} [time] viewer headline 확인 시각
338
+ * @property {React.ReactNode} [metrics] viewer 하단 sensor metric 영역
339
+ * @property {() => void} [onPrev] 이전 카메라 이동 핸들러
340
+ * @property {() => void} [onNext] 다음 카메라 이동 핸들러
274
341
  */
275
342
  export interface CctvViewerDesktopContainerProps {
276
343
  /**
@@ -293,4 +360,20 @@ export interface CctvViewerDesktopContainerProps {
293
360
  * viewer video용 custom overlay 렌더 함수
294
361
  */
295
362
  renderOverlay?: CctvVideoRenderOverlay;
363
+ /**
364
+ * viewer headline 확인 시각
365
+ */
366
+ time?: React.ReactNode;
367
+ /**
368
+ * viewer 하단 sensor metric 영역
369
+ */
370
+ metrics?: React.ReactNode;
371
+ /**
372
+ * 이전 카메라 이동 핸들러
373
+ */
374
+ onPrev?: () => void;
375
+ /**
376
+ * 다음 카메라 이동 핸들러
377
+ */
378
+ onNext?: () => void;
296
379
  }