@uniai-fe/uds-templates 0.10.3 → 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.3",
3
+ "version": "0.11.0",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -56,28 +56,28 @@
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
- "@uniai-fe/eslint-config": "0.4.1",
71
70
  "@uniai-fe/next-devkit": "0.4.0",
72
- "@uniai-fe/tsconfig": "0.2.0",
73
- "@uniai-fe/react-hooks": "0.3.0",
71
+ "@uniai-fe/uds-primitives": "0.10.2",
74
72
  "@uniai-fe/uds-foundation": "0.5.0",
75
- "@uniai-fe/uds-primitives": "0.10.0",
76
- "@uniai-fe/util-functions": "0.4.3",
73
+ "@uniai-fe/eslint-config": "0.4.2",
74
+ "@uniai-fe/react-hooks": "0.3.0",
75
+ "@uniai-fe/tsconfig": "0.2.0",
77
76
  "@uniai-fe/util-api": "0.2.1",
78
- "@uniai-fe/util-next": "0.5.0",
79
- "@uniai-fe/util-rtc": "0.2.0",
80
- "@uniai-fe/util-jotai": "0.3.0"
77
+ "@uniai-fe/util-functions": "0.4.3",
78
+ "@uniai-fe/util-jotai": "0.3.0",
79
+ "@uniai-fe/util-rtc": "0.2.1",
80
+ "@uniai-fe/util-next": "0.5.0"
81
81
  },
82
82
  "scripts": {
83
83
  "check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
@@ -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
  )}