@uniai-fe/uds-templates 0.10.4 → 0.11.1
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 +192 -29
- package/package.json +14 -14
- package/src/cctv/components/cam-list/Item.tsx +4 -3
- package/src/cctv/components/pagination/Provider.tsx +6 -2
- package/src/cctv/components/pagination/list/Item.tsx +1 -0
- package/src/cctv/components/video/Card.tsx +30 -0
- package/src/cctv/components/video/Container.tsx +3 -0
- package/src/cctv/components/video/Template.tsx +6 -1
- package/src/cctv/components/video/index.tsx +2 -0
- package/src/cctv/components/video/overlay/footer/Container.tsx +2 -0
- package/src/cctv/components/video/overlay/footer/Metric.tsx +22 -0
- package/src/cctv/components/video/overlay/footer/Metrics.tsx +13 -0
- package/src/cctv/components/video/overlay/header/LiveState.tsx +1 -1
- package/src/cctv/components/video/overlay/index.tsx +4 -0
- package/src/cctv/components/viewer/desktop/Container.tsx +35 -4
- package/src/cctv/components/viewer/desktop/Headline.tsx +28 -0
- package/src/cctv/components/viewer/desktop/Overlay.tsx +60 -0
- package/src/cctv/components/viewer/desktop/Video.tsx +17 -5
- package/src/cctv/components/viewer/index.tsx +4 -0
- package/src/cctv/img/time.svg +4 -0
- package/src/cctv/styles/cam-list.scss +2 -25
- package/src/cctv/styles/pagination.scss +2 -1
- package/src/cctv/styles/variables.scss +18 -1
- package/src/cctv/styles/video.scss +95 -5
- package/src/cctv/styles/viewer.scss +73 -2
- package/src/cctv/types/props.ts +83 -0
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:
|
|
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:
|
|
1803
|
-
height:
|
|
1804
|
-
margin-right:
|
|
1805
|
-
border-radius:
|
|
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-
|
|
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,11 @@
|
|
|
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) {
|
|
1972
|
-
.cctv-cam-list-track {
|
|
1973
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
@container cctv-cam-list (min-width: 984px) {
|
|
1977
|
-
.cctv-cam-list-track {
|
|
1978
|
-
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
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
2084
|
.cctv-pagination-container {
|
|
1992
2085
|
width: 100%;
|
|
1993
2086
|
display: flex;
|
|
@@ -2027,7 +2120,7 @@
|
|
|
2027
2120
|
}
|
|
2028
2121
|
|
|
2029
2122
|
.cctv-pagination-track > li {
|
|
2030
|
-
flex: 0 0
|
|
2123
|
+
flex: 0 0 var(--cctv-pagination-item-width);
|
|
2031
2124
|
}
|
|
2032
2125
|
|
|
2033
2126
|
.cctv-pagination-list-item {
|
|
@@ -2035,6 +2128,7 @@
|
|
|
2035
2128
|
}
|
|
2036
2129
|
|
|
2037
2130
|
.cctv-pagination-list-item .cctv-video-container {
|
|
2131
|
+
min-height: 0;
|
|
2038
2132
|
max-height: 152px;
|
|
2039
2133
|
}
|
|
2040
2134
|
|
|
@@ -2060,12 +2154,81 @@
|
|
|
2060
2154
|
background: var(--cctv-pagination-move-button-disabled);
|
|
2061
2155
|
}
|
|
2062
2156
|
|
|
2157
|
+
.cctv-viewer-desktop-container {
|
|
2158
|
+
display: grid;
|
|
2159
|
+
gap: var(--spacing-gap-6);
|
|
2160
|
+
width: min(100%, 1216px);
|
|
2161
|
+
margin: 0 auto;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
.cctv-viewer-desktop-headline {
|
|
2165
|
+
min-height: 34px;
|
|
2166
|
+
display: flex;
|
|
2167
|
+
align-items: center;
|
|
2168
|
+
justify-content: space-between;
|
|
2169
|
+
gap: var(--spacing-gap-6);
|
|
2170
|
+
}
|
|
2171
|
+
.cctv-viewer-desktop-headline h3 {
|
|
2172
|
+
min-width: 0;
|
|
2173
|
+
margin: 0;
|
|
2174
|
+
overflow: hidden;
|
|
2175
|
+
color: var(--color-label-strong);
|
|
2176
|
+
font-size: var(--font-heading-medium-size);
|
|
2177
|
+
line-height: 1.4;
|
|
2178
|
+
font-weight: 700;
|
|
2179
|
+
text-overflow: ellipsis;
|
|
2180
|
+
white-space: nowrap;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
.cctv-viewer-desktop-time {
|
|
2184
|
+
display: inline-flex;
|
|
2185
|
+
align-items: center;
|
|
2186
|
+
gap: var(--spacing-gap-3);
|
|
2187
|
+
color: var(--color-label-neutral);
|
|
2188
|
+
font-size: var(--font-label-small-size);
|
|
2189
|
+
line-height: var(--font-label-small-line-height);
|
|
2190
|
+
font-weight: var(--font-label-small-weight);
|
|
2191
|
+
white-space: nowrap;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2063
2194
|
.cctv-viewer-desktop-video-container {
|
|
2064
|
-
|
|
2195
|
+
--cctv-video-radius: var(--cctv-viewer-video-radius);
|
|
2196
|
+
--cctv-overlay-padding-x: var(--cctv-viewer-overlay-padding-x);
|
|
2197
|
+
--cctv-overlay-padding-y: var(--cctv-viewer-overlay-padding-y);
|
|
2198
|
+
max-height: var(--cctv-viewer-video-max-height);
|
|
2199
|
+
min-height: 0;
|
|
2200
|
+
aspect-ratio: 608/309;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
.cctv-viewer-desktop-overlay-body {
|
|
2204
|
+
flex-direction: row;
|
|
2205
|
+
justify-content: space-between;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
.cctv-viewer-desktop-move-button.cctv-pagination-move-button {
|
|
2209
|
+
width: 42px;
|
|
2210
|
+
height: 42px;
|
|
2211
|
+
background: var(--cctv-pagination-move-button-active);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
.cctv-viewer-desktop-video-container .cctv-video-overlay-footer {
|
|
2215
|
+
justify-content: flex-end;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
.cctv-visually-hidden {
|
|
2219
|
+
position: absolute;
|
|
2220
|
+
width: 1px;
|
|
2221
|
+
height: 1px;
|
|
2222
|
+
padding: 0;
|
|
2223
|
+
margin: -1px;
|
|
2224
|
+
overflow: hidden;
|
|
2225
|
+
clip: rect(0, 0, 0, 0);
|
|
2226
|
+
white-space: nowrap;
|
|
2227
|
+
border: 0;
|
|
2065
2228
|
}
|
|
2066
2229
|
|
|
2067
2230
|
.cctv-viewer-desktop-pagination-container {
|
|
2068
|
-
margin-top: var(--spacing-gap-
|
|
2231
|
+
margin-top: var(--spacing-gap-2);
|
|
2069
2232
|
}
|
|
2070
2233
|
|
|
2071
2234
|
.service-inquiry-form {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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.
|
|
60
|
-
"@types/node": "^24.13.
|
|
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.
|
|
63
|
+
"eslint": "^9.39.5",
|
|
64
64
|
"jotai": "^2.20.2",
|
|
65
65
|
"next": "16.2.10",
|
|
66
|
-
"prettier": "^3.
|
|
67
|
-
"react-hook-form": "^7.
|
|
68
|
-
"sass": "^1.101.
|
|
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/react-hooks": "0.3.0",
|
|
71
|
+
"@uniai-fe/eslint-config": "0.4.2",
|
|
70
72
|
"@uniai-fe/next-devkit": "0.4.0",
|
|
71
|
-
"@uniai-fe/eslint-config": "0.4.1",
|
|
72
73
|
"@uniai-fe/tsconfig": "0.2.0",
|
|
73
|
-
"@uniai-fe/
|
|
74
|
-
"@uniai-fe/util-api": "0.2.1",
|
|
75
|
-
"@uniai-fe/uds-primitives": "0.10.0",
|
|
76
|
-
"@uniai-fe/util-functions": "0.4.3",
|
|
74
|
+
"@uniai-fe/uds-primitives": "0.10.2",
|
|
77
75
|
"@uniai-fe/util-jotai": "0.3.0",
|
|
78
|
-
"@uniai-fe/util-rtc": "0.2.1",
|
|
79
76
|
"@uniai-fe/uds-foundation": "0.5.0",
|
|
80
|
-
"@uniai-fe/util-next": "0.5.0"
|
|
77
|
+
"@uniai-fe/util-next": "0.5.0",
|
|
78
|
+
"@uniai-fe/util-functions": "0.4.3",
|
|
79
|
+
"@uniai-fe/util-api": "0.2.1",
|
|
80
|
+
"@uniai-fe/util-rtc": "0.2.1"
|
|
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
|
-
|
|
53
|
+
layout="card"
|
|
54
|
+
// list card에서는 live/share 상태와 제목을 header에 함께 노출한다.
|
|
54
55
|
headerOptions={{
|
|
55
56
|
activeLiveState: true,
|
|
56
|
-
activeTitle:
|
|
57
|
+
activeTitle: true,
|
|
57
58
|
isLive,
|
|
58
59
|
isShared: cam.is_public === true,
|
|
59
60
|
title: cam.cam_name,
|
|
60
61
|
}}
|
|
61
|
-
footerOptions={{ activeTitle:
|
|
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(
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
36
|
-
? { cam:
|
|
62
|
+
{...(typeof resolvedCam !== "undefined"
|
|
63
|
+
? { cam: resolvedCam }
|
|
37
64
|
: {})}
|
|
38
|
-
|
|
65
|
+
metrics={metrics}
|
|
66
|
+
onNext={resolvedNext}
|
|
67
|
+
onPrev={resolvedPrev}
|
|
39
68
|
renderOverlay={renderOverlay}
|
|
40
69
|
/>
|
|
41
70
|
<CCTVViewerDesktopPagination
|
|
42
|
-
{...(typeof
|
|
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
|
-
|
|
59
|
-
|
|
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
|
};
|
|
@@ -32,30 +32,7 @@
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
.cctv-cam-list-item .cctv-video-container {
|
|
35
|
+
min-height: 0;
|
|
35
36
|
max-height: 240px;
|
|
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
|
-
}
|
|
37
|
+
aspect-ratio: 16 / 9;
|
|
61
38
|
}
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
.cctv-pagination-track > li {
|
|
42
42
|
// panel 너비를 고정해 viewport 이동 시 균등하게 보이도록 한다.
|
|
43
|
-
flex: 0 0
|
|
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:
|
|
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:
|
|
79
|
-
height:
|
|
80
|
-
margin-right:
|
|
81
|
-
border-radius:
|
|
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-
|
|
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
|
-
|
|
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-
|
|
77
|
+
margin-top: var(--spacing-gap-2);
|
|
7
78
|
}
|
package/src/cctv/types/props.ts
CHANGED
|
@@ -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
|
}
|