@uniai-fe/uds-templates 0.1.20 → 0.1.22

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.
Files changed (79) hide show
  1. package/dist/styles.css +2 -2
  2. package/package.json +8 -4
  3. package/src/auth/login/jotai/user.ts +13 -0
  4. package/src/auth/login/types/api.ts +229 -0
  5. package/src/auth/login/types/form.ts +1 -0
  6. package/src/auth/login/types/index.ts +4 -0
  7. package/src/auth/signup/markup/VerificationForm.tsx +3 -2
  8. package/src/cctv/apis/client.ts +61 -0
  9. package/src/cctv/apis/index.ts +2 -0
  10. package/src/cctv/apis/server.ts +188 -0
  11. package/src/cctv/components/Provider.tsx +47 -0
  12. package/src/cctv/components/__viewer.tsx +99 -0
  13. package/src/cctv/components/cam-list/Container.tsx +36 -0
  14. package/src/cctv/components/cam-list/Item.tsx +71 -0
  15. package/src/cctv/components/cam-list/index.tsx +7 -0
  16. package/src/cctv/components/index.tsx +13 -0
  17. package/src/cctv/components/pagination/Container.tsx +26 -0
  18. package/src/cctv/components/pagination/Control.tsx +29 -0
  19. package/src/cctv/components/pagination/Provider.tsx +204 -0
  20. package/src/cctv/components/pagination/buttons/Base.tsx +56 -0
  21. package/src/cctv/components/pagination/buttons/Next.tsx +34 -0
  22. package/src/cctv/components/pagination/buttons/Prev.tsx +34 -0
  23. package/src/cctv/components/pagination/index.tsx +25 -0
  24. package/src/cctv/components/pagination/list/Carousel.tsx +26 -0
  25. package/src/cctv/components/pagination/list/Container.tsx +30 -0
  26. package/src/cctv/components/pagination/list/Item.tsx +81 -0
  27. package/src/cctv/components/video/Container.tsx +13 -0
  28. package/src/cctv/components/video/Video.tsx +34 -0
  29. package/src/cctv/components/video/index.tsx +9 -0
  30. package/src/cctv/components/video/overlay/Container.tsx +15 -0
  31. package/src/cctv/components/video/overlay/Title.tsx +28 -0
  32. package/src/cctv/components/video/overlay/body/Container.tsx +13 -0
  33. package/src/cctv/components/video/overlay/body/Error.tsx +16 -0
  34. package/src/cctv/components/video/overlay/footer/Container.tsx +30 -0
  35. package/src/cctv/components/video/overlay/footer/OpenButton.tsx +19 -0
  36. package/src/cctv/components/video/overlay/header/CloseButton.tsx +14 -0
  37. package/src/cctv/components/video/overlay/header/Container.tsx +50 -0
  38. package/src/cctv/components/video/overlay/header/LiveState.tsx +21 -0
  39. package/src/cctv/components/video/overlay/index.tsx +24 -0
  40. package/src/cctv/components/viewer/Container.tsx +13 -0
  41. package/src/cctv/components/viewer/desktop/Container.tsx +38 -0
  42. package/src/cctv/components/viewer/desktop/Pagination.tsx +20 -0
  43. package/src/cctv/components/viewer/desktop/Placeholder.tsx +18 -0
  44. package/src/cctv/components/viewer/desktop/Video.tsx +83 -0
  45. package/src/cctv/components/viewer/index.tsx +12 -0
  46. package/src/cctv/components/viewer/mobile/Container.tsx +13 -0
  47. package/src/cctv/data/context.ts +22 -0
  48. package/src/cctv/data/index.ts +1 -0
  49. package/src/cctv/hooks/index.tsx +5 -0
  50. package/src/cctv/hooks/useCompanyData.tsx +39 -0
  51. package/src/cctv/hooks/useContext.ts +150 -0
  52. package/src/cctv/hooks/useRtcStream.ts +94 -0
  53. package/src/cctv/img/chevron-left.svg +3 -0
  54. package/src/cctv/img/chevron-right.svg +3 -0
  55. package/src/cctv/img/error.svg +4 -0
  56. package/src/cctv/img/viewer-close.svg +3 -0
  57. package/src/cctv/img/viewer-open.svg +6 -0
  58. package/src/cctv/index.scss +1 -0
  59. package/src/cctv/index.tsx +9 -0
  60. package/src/cctv/jotai/context.ts +9 -0
  61. package/src/cctv/jotai/index.ts +1 -0
  62. package/src/cctv/styles/cam-list.scss +32 -0
  63. package/src/cctv/styles/index.scss +5 -0
  64. package/src/cctv/styles/pagination.scss +77 -0
  65. package/src/cctv/styles/variables.scss +38 -0
  66. package/src/cctv/styles/video.scss +142 -0
  67. package/src/cctv/styles/viewer.scss +7 -0
  68. package/src/cctv/types/api.ts +166 -0
  69. package/src/cctv/types/carousel.ts +24 -0
  70. package/src/cctv/types/context.ts +68 -0
  71. package/src/cctv/types/index.ts +4 -0
  72. package/src/cctv/types/list.ts +94 -0
  73. package/src/cctv/utils/data.ts +40 -0
  74. package/src/cctv/utils/select.ts +62 -0
  75. package/src/index.tsx +3 -0
  76. package/src/modal/styles/base.scss +2 -2
  77. package/src/types/api.ts +43 -0
  78. package/src/types/index.ts +1 -0
  79. package/src/auth/login/types.ts +0 -2
@@ -0,0 +1,99 @@
1
+ // "use client";
2
+
3
+ // import clsx from "clsx";
4
+
5
+ // import CCTVVideoContainer from "./video/Container";
6
+ // import CCTVVideoContents from "./video/Video";
7
+ // import CCTVVideoOverlayContainer from "./video/overlay/Container";
8
+ // import CCTVVideoOverlayHeader from "./video/overlay/header/Container";
9
+ // import CCTVVideoOverlayBody from "./video/overlay/body/Container";
10
+ // import CCTVVideoError from "./video/overlay/body/Error";
11
+ // import CCTVVideoOverlayFooter from "./video/overlay/footer/Container";
12
+ // import { CCTVPagination } from "./pagination";
13
+ // import useCctvContext from "../hooks/useContext";
14
+
15
+ // export default function CCTVViewer({ className }: { className?: string }) {
16
+ // const { selectedCompany, selectedCam, cams, companyValidList, isFetching } =
17
+ // useCctvContext();
18
+
19
+ // const totalCamCount = selectedCompany?.cam_list.length ?? 0;
20
+ // const onlineCamCount = selectedCompany
21
+ // ? selectedCompany.cam_list.filter(({ cam_online }) => cam_online).length
22
+ // : 0;
23
+
24
+ // const renderVideoSection = () => {
25
+ // if (isFetching && !selectedCam) {
26
+ // return <ViewerPlaceholder message="CCTV 데이터를 불러오는 중입니다." />;
27
+ // }
28
+
29
+ // if (!selectedCam) {
30
+ // return <ViewerPlaceholder message="좌측 목록에서 카메라를 선택하세요." />;
31
+ // }
32
+
33
+ // const isCamOffline = !selectedCam.cam_online;
34
+
35
+ // return (
36
+ // <CCTVVideoContainer>
37
+ // <CCTVVideoContents />
38
+ // <CCTVVideoOverlayContainer>
39
+ // <CCTVVideoOverlayHeader
40
+ // activeLiveState
41
+ // activeTitle
42
+ // activeTime
43
+ // activeCloseButton
44
+ // liveStateDisabled={isCamOffline}
45
+ // />
46
+ // {isCamOffline && (
47
+ // <CCTVVideoOverlayBody className="is-error">
48
+ // <CCTVVideoError />
49
+ // </CCTVVideoOverlayBody>
50
+ // )}
51
+ // <CCTVVideoOverlayFooter
52
+ // activeTitle
53
+ // activeOpenButton={false}
54
+ // title={selectedCam.cam_name}
55
+ // />
56
+ // </CCTVVideoOverlayContainer>
57
+ // </CCTVVideoContainer>
58
+ // );
59
+ // };
60
+
61
+ // return (
62
+ // <section className={clsx("cctv-viewer", className)}>
63
+ // <header className="cctv-viewer-header">
64
+ // <div className="cctv-viewer-header-title">
65
+ // <p className="cctv-viewer-company-name">
66
+ // {selectedCompany?.company_name ?? "CCTV Viewer"}
67
+ // </p>
68
+ // <p className="cctv-viewer-company-meta">
69
+ // {selectedCompany
70
+ // ? `총 ${totalCamCount}대 중 ${onlineCamCount}대 가동`
71
+ // : "카메라를 선택하면 상세 정보를 볼 수 있습니다."}
72
+ // </p>
73
+ // </div>
74
+ // <div className="cctv-viewer-company-summary">
75
+ // <span>
76
+ // 연결된 업체 <strong>{companyValidList.length}</strong>
77
+ // </span>
78
+ // </div>
79
+ // </header>
80
+
81
+ // <div className="cctv-viewer-body">
82
+ // <div className="cctv-viewer-main">{renderVideoSection()}</div>
83
+ // <div className="cctv-viewer-carousel">
84
+ // <div className="cctv-viewer-carousel-header">
85
+ // <h4>카메라 목록</h4>
86
+ // <span>{cams.length}대</span>
87
+ // </div>
88
+ // <CCTVPagination.Container>
89
+ // <CCTVPagination.List.Container list={cams} />
90
+ // </CCTVPagination.Container>
91
+ // </div>
92
+ // </div>
93
+ // </section>
94
+ // );
95
+ // }
96
+
97
+ // function ViewerPlaceholder({ message }: { message: string }) {
98
+ // return <div className="cctv-viewer-placeholder">{message}</div>;
99
+ // }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import CCTVCamListItem from "./Item";
5
+ import type { CctvCompanyCameraList } from "../../types";
6
+ import { useCctvContext } from "../../hooks";
7
+ import { useMemo } from "react";
8
+
9
+ export default function CCTVCamListContainer({
10
+ className,
11
+ list,
12
+ }: {
13
+ className?: string;
14
+ list?: CctvCompanyCameraList[];
15
+ }) {
16
+ const { cams } = useCctvContext();
17
+ const camList = useMemo(() => list ?? cams, [list, cams]);
18
+ // console.log("CCTVCamListContainer camList:", { list, camList });
19
+
20
+ if (camList.length === 0)
21
+ return (
22
+ <div className={clsx("cctv-cam-list-container", className)}>
23
+ <div className="cctv-cam-list-empty">데이터 없음</div>
24
+ </div>
25
+ );
26
+
27
+ return (
28
+ <div className={clsx("cctv-cam-list-container", className)}>
29
+ <ul className="cctv-cam-list-track">
30
+ {camList.map(({ renderKey, ...cam }) => (
31
+ <CCTVCamListItem key={renderKey} {...cam} />
32
+ ))}
33
+ </ul>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,71 @@
1
+ import clsx from "clsx";
2
+ import type { CctvCompanyCameraList } from "../../types";
3
+ import CCTVVideoContainer from "../video/Container";
4
+ import CCTVVideoOverlayBody from "../video/overlay/body/Container";
5
+ import CCTVVideoError from "../video/overlay/body/Error";
6
+ import CCTVVideoOverlayContainer from "../video/overlay/Container";
7
+ import CCTVVideoOverlayFooter from "../video/overlay/footer/Container";
8
+ import CCTVVideoOverlayHeader from "../video/overlay/header/Container";
9
+ import CCTVVideoContents from "../video/Video";
10
+ import { useCctvRtcStream } from "../../hooks";
11
+ import { useMemo } from "react";
12
+
13
+ export default function CCTVCamListItem({
14
+ className,
15
+ ...cam
16
+ }: { className?: string } & Omit<CctvCompanyCameraList, "renderKey">) {
17
+ const { videoRef, isTokenLoading, isTokenError, isStreaming, streamError } =
18
+ useCctvRtcStream({
19
+ cam,
20
+ companyId: cam.company_id,
21
+ });
22
+
23
+ const overlayMessage = (() => {
24
+ if (!cam.cam_online) return "CCTV 연결 오류";
25
+ if (isTokenLoading || isStreaming) return "스트림을 준비하고 있습니다.";
26
+ if (isTokenError) return "토큰을 발급하지 못했습니다.";
27
+ if (streamError) return streamError;
28
+ return null;
29
+ })();
30
+
31
+ const isError = useMemo(
32
+ () => !cam.cam_online || isTokenError || streamError,
33
+ [cam.cam_online, isTokenError, streamError],
34
+ );
35
+
36
+ const disabledLiveState = useMemo(() => {
37
+ if (!cam.cam_online) return true;
38
+ if (isTokenLoading || isTokenError) return true;
39
+ if (streamError) return true;
40
+ return false;
41
+ }, [cam.cam_online, isTokenLoading, isTokenError, streamError]);
42
+
43
+ return (
44
+ <li
45
+ className={clsx(
46
+ "cctv-cam-list-item",
47
+ className,
48
+ cam.selected && "is-selected",
49
+ isError && "is-error",
50
+ )}
51
+ >
52
+ <CCTVVideoContainer>
53
+ <CCTVVideoContents ref={videoRef} muted />
54
+ <CCTVVideoOverlayContainer>
55
+ <CCTVVideoOverlayHeader
56
+ activeLiveState
57
+ activeTitle={false}
58
+ liveStateDisabled={disabledLiveState}
59
+ title={cam.cam_name}
60
+ />
61
+ <CCTVVideoOverlayBody className={isError ? "is-error" : undefined}>
62
+ {disabledLiveState ? (
63
+ <CCTVVideoError>{overlayMessage}</CCTVVideoError>
64
+ ) : null}
65
+ </CCTVVideoOverlayBody>
66
+ <CCTVVideoOverlayFooter activeTitle activeOpenButton cam={cam} />
67
+ </CCTVVideoOverlayContainer>
68
+ </CCTVVideoContainer>
69
+ </li>
70
+ );
71
+ }
@@ -0,0 +1,7 @@
1
+ import CCTVCamListContainer from "./Container";
2
+ import CCTVCamListItem from "./Item";
3
+
4
+ export const CCTVCamList = {
5
+ Container: CCTVCamListContainer,
6
+ Item: CCTVCamListItem,
7
+ };
@@ -0,0 +1,13 @@
1
+ import { CCTVCamList } from "./cam-list";
2
+ import { CCTVPagination } from "./pagination";
3
+ import CCTVProvider from "./Provider";
4
+ import { CCTVVideo } from "./video";
5
+ import { CCTVViewer } from "./viewer";
6
+
7
+ export const CCTV = {
8
+ Provider: CCTVProvider,
9
+ Video: CCTVVideo,
10
+ Viewer: CCTVViewer,
11
+ CamList: CCTVCamList,
12
+ Pagination: CCTVPagination,
13
+ };
@@ -0,0 +1,26 @@
1
+ import clsx from "clsx";
2
+ import { CctvPaginationCarouselProvider } from "./Provider";
3
+ import CCTVPaginationControl from "./Control";
4
+
5
+ export default function CCTVPaginationContainer({
6
+ className,
7
+ children,
8
+ activeButton = true,
9
+ }: {
10
+ className?: string;
11
+ children: React.ReactNode;
12
+ activeButton?: boolean;
13
+ }) {
14
+ // Carousel Provider로 감싸 버튼/리스트가 동일한 스크롤 상태를 공유하게 한다.
15
+ return (
16
+ <CctvPaginationCarouselProvider>
17
+ <div className={clsx("cctv-pagination-container", className)}>
18
+ {activeButton ? (
19
+ <CCTVPaginationControl>{children}</CCTVPaginationControl>
20
+ ) : (
21
+ children
22
+ )}
23
+ </div>
24
+ </CctvPaginationCarouselProvider>
25
+ );
26
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import CCTVPaginationPrevButton from "./buttons/Prev";
4
+ import CCTVPaginationNextButton from "./buttons/Next";
5
+ import { useCctvPaginationCarousel } from "./Provider";
6
+
7
+ export default function CCTVPaginationControl({
8
+ children,
9
+ }: {
10
+ children: React.ReactNode;
11
+ }) {
12
+ const carousel = useCctvPaginationCarousel();
13
+
14
+ if (!carousel) return children;
15
+
16
+ return (
17
+ <>
18
+ <CCTVPaginationPrevButton
19
+ onPrev={carousel.onPrev}
20
+ disabled={carousel?.isReachStart}
21
+ />
22
+ {children}
23
+ <CCTVPaginationNextButton
24
+ onNext={carousel.onNext}
25
+ disabled={carousel?.isReachEnd}
26
+ />
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import type { PropsWithChildren } from "react";
13
+ import type {
14
+ CctvPaginationCarouselContextValue,
15
+ CctvPaginationCarouselProviderProps,
16
+ } from "../../types";
17
+
18
+ const CctvPaginationCarouselContext =
19
+ createContext<CctvPaginationCarouselContextValue | null>(null);
20
+
21
+ const DEFAULT_VISIBLE_COUNT = 1;
22
+ const SCROLL_SETTLE_MS = 240;
23
+
24
+ export function CctvPaginationCarouselProvider({
25
+ children,
26
+ }: PropsWithChildren<CctvPaginationCarouselProviderProps>) {
27
+ const viewportRef = useRef<HTMLDivElement | null>(null);
28
+ const trackRef = useRef<HTMLUListElement | null>(null);
29
+ const scrollSettleTimer = useRef<number | null>(null);
30
+ const isProgrammaticScroll = useRef(false);
31
+ const scrollFrame = useRef<number | null>(null);
32
+ const [itemCount, setItemCount] = useState(0);
33
+ const [visibleCount, setVisibleCount] = useState(DEFAULT_VISIBLE_COUNT);
34
+ const [currentIndex, setCurrentIndex] = useState(0);
35
+
36
+ const maxIndex = useMemo(
37
+ () => Math.max(itemCount - visibleCount, 0),
38
+ [itemCount, visibleCount],
39
+ );
40
+
41
+ const registerItemCount = useCallback((count: number) => {
42
+ setItemCount(count);
43
+ setCurrentIndex(prev => Math.min(prev, Math.max(count - 1, 0)));
44
+ }, []);
45
+
46
+ const updateVisibleCount = useCallback(() => {
47
+ if (!viewportRef.current || !trackRef.current?.children.length) {
48
+ setVisibleCount(DEFAULT_VISIBLE_COUNT);
49
+ return;
50
+ }
51
+ const first = trackRef.current.children[0] as HTMLElement;
52
+ const second = trackRef.current.children[1] as HTMLElement | undefined;
53
+ const viewportWidth = viewportRef.current.clientWidth;
54
+ const panelWidth = second
55
+ ? second.offsetLeft - first.offsetLeft
56
+ : first.getBoundingClientRect().width;
57
+ if (panelWidth <= 0) {
58
+ setVisibleCount(DEFAULT_VISIBLE_COUNT);
59
+ return;
60
+ }
61
+ const slots = Math.max(Math.floor(viewportWidth / panelWidth), 1);
62
+ setVisibleCount(slots);
63
+ setCurrentIndex(prev => Math.min(prev, Math.max(itemCount - slots, 0)));
64
+ }, [itemCount]);
65
+
66
+ useEffect(() => {
67
+ updateVisibleCount();
68
+ }, [updateVisibleCount, itemCount]);
69
+
70
+ useEffect(() => {
71
+ if (!viewportRef.current || typeof ResizeObserver === "undefined") return;
72
+ const observer = new ResizeObserver(() => {
73
+ updateVisibleCount();
74
+ });
75
+ observer.observe(viewportRef.current);
76
+ return () => observer.disconnect();
77
+ }, [updateVisibleCount]);
78
+
79
+ const clampIndex = useCallback(
80
+ (index: number) => Math.max(0, Math.min(index, maxIndex)),
81
+ [maxIndex],
82
+ );
83
+
84
+ const scrollToIndex = useCallback((index: number) => {
85
+ if (!viewportRef.current || !trackRef.current) return;
86
+ const panel = trackRef.current.children[index] as HTMLElement | undefined;
87
+ if (!panel) return;
88
+ const targetLeft = panel.offsetLeft;
89
+ const currentLeft = viewportRef.current.scrollLeft;
90
+ if (Math.abs(currentLeft - targetLeft) < 1) {
91
+ setCurrentIndex(index);
92
+ return;
93
+ }
94
+
95
+ // 버튼 이동 시 부드러운 native scroll을 유지하되, 완료 시점까지 상태를 잠시 고정한다.
96
+ isProgrammaticScroll.current = true;
97
+ viewportRef.current.scrollTo({
98
+ left: targetLeft,
99
+ behavior: "smooth",
100
+ });
101
+ setCurrentIndex(index);
102
+ if (scrollSettleTimer.current) {
103
+ window.clearTimeout(scrollSettleTimer.current);
104
+ }
105
+ scrollSettleTimer.current = window.setTimeout(() => {
106
+ isProgrammaticScroll.current = false;
107
+ }, SCROLL_SETTLE_MS);
108
+ }, []);
109
+
110
+ const moveTo = useCallback(
111
+ (index: number) => {
112
+ const nextIndex = clampIndex(index);
113
+ if (nextIndex === currentIndex) return;
114
+ scrollToIndex(nextIndex);
115
+ },
116
+ [clampIndex, currentIndex, scrollToIndex],
117
+ );
118
+
119
+ const onPrev = useCallback(() => {
120
+ moveTo(currentIndex - 1);
121
+ }, [currentIndex, moveTo]);
122
+
123
+ const onNext = useCallback(() => {
124
+ moveTo(currentIndex + 1);
125
+ }, [currentIndex, moveTo]);
126
+
127
+ useEffect(() => {
128
+ const viewport = viewportRef.current;
129
+ if (!viewport) return;
130
+ const handleScroll = () => {
131
+ if (isProgrammaticScroll.current || !trackRef.current) return;
132
+ if (scrollFrame.current) {
133
+ cancelAnimationFrame(scrollFrame.current);
134
+ }
135
+ scrollFrame.current = requestAnimationFrame(() => {
136
+ const { scrollLeft } = viewport;
137
+ const panels = trackRef.current?.children ?? [];
138
+ if (!panels.length) return;
139
+ let closestIndex = 0;
140
+ let smallestDistance = Number.POSITIVE_INFINITY;
141
+ for (let i = 0; i < panels.length; i += 1) {
142
+ const panel = panels[i] as HTMLElement;
143
+ const distance = Math.abs(panel.offsetLeft - scrollLeft);
144
+ if (distance < smallestDistance) {
145
+ smallestDistance = distance;
146
+ closestIndex = i;
147
+ }
148
+ }
149
+ const resolvedIndex = Math.min(closestIndex, maxIndex);
150
+ setCurrentIndex(prev =>
151
+ prev === resolvedIndex ? prev : resolvedIndex,
152
+ );
153
+ });
154
+ };
155
+ viewport.addEventListener("scroll", handleScroll, { passive: true });
156
+ return () => {
157
+ viewport.removeEventListener("scroll", handleScroll);
158
+ if (scrollFrame.current) {
159
+ cancelAnimationFrame(scrollFrame.current);
160
+ scrollFrame.current = null;
161
+ }
162
+ };
163
+ }, [maxIndex]);
164
+
165
+ useEffect(() => {
166
+ return () => {
167
+ if (scrollSettleTimer.current) {
168
+ window.clearTimeout(scrollSettleTimer.current);
169
+ }
170
+ };
171
+ }, []);
172
+
173
+ const value = useMemo<CctvPaginationCarouselContextValue>(
174
+ () => ({
175
+ viewportRef,
176
+ trackRef,
177
+ onPrev,
178
+ onNext,
179
+ moveTo,
180
+ registerItemCount,
181
+ currentIndex,
182
+ maxIndex,
183
+ isReachStart: currentIndex <= 0,
184
+ isReachEnd: currentIndex >= maxIndex,
185
+ }),
186
+ [currentIndex, maxIndex, moveTo, onNext, onPrev, registerItemCount],
187
+ );
188
+
189
+ return (
190
+ <CctvPaginationCarouselContext.Provider value={value}>
191
+ {children}
192
+ </CctvPaginationCarouselContext.Provider>
193
+ );
194
+ }
195
+
196
+ export function useCctvPaginationCarousel(options?: {
197
+ optional?: boolean;
198
+ }): CctvPaginationCarouselContextValue | null {
199
+ const context = useContext(CctvPaginationCarouselContext);
200
+ if (!context && !options?.optional) {
201
+ throw new Error("CctvPaginationCarouselProvider is required.");
202
+ }
203
+ return context;
204
+ }
@@ -0,0 +1,56 @@
1
+ import clsx from "clsx";
2
+ import LeftIcon from "../../../img/chevron-left.svg";
3
+ import RightIcon from "../../../img/chevron-right.svg";
4
+
5
+ function IconFigure({
6
+ className,
7
+ children,
8
+ }: {
9
+ className?: string;
10
+ children: React.ReactNode;
11
+ }) {
12
+ return (
13
+ <figure className={clsx("cctv-pagination-move-button-icon", className)}>
14
+ {children}
15
+ </figure>
16
+ );
17
+ }
18
+
19
+ export default function CCTVPaginationBaseButton({
20
+ className,
21
+ direction,
22
+ children,
23
+ onMove,
24
+ disabled,
25
+ }: {
26
+ className?: string;
27
+ children?: React.ReactNode;
28
+ direction: "prev" | "next";
29
+ onMove: () => void;
30
+ disabled?: boolean;
31
+ }) {
32
+ return (
33
+ <button
34
+ type="button"
35
+ className={clsx(
36
+ "cctv-pagination-move-button",
37
+ `cctv-${direction}-button`,
38
+ className,
39
+ )}
40
+ disabled={disabled}
41
+ onClick={onMove}
42
+ >
43
+ {direction === "prev" && (
44
+ <IconFigure className="cctv-prev-icon">
45
+ <LeftIcon width={24} height={24} alt="Previous CCTV" />
46
+ </IconFigure>
47
+ )}
48
+ {children}
49
+ {direction === "next" && (
50
+ <IconFigure className="cctv-next-icon">
51
+ <RightIcon width={24} height={24} alt="Next CCTV" />
52
+ </IconFigure>
53
+ )}
54
+ </button>
55
+ );
56
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import CCTVPaginationBaseButton from "./Base";
5
+ import { useCctvPaginationCarousel } from "../Provider";
6
+
7
+ export default function CCTVPaginationNextButton({
8
+ children,
9
+ onNext,
10
+ disabled,
11
+ }: {
12
+ children?: React.ReactNode;
13
+ onNext?: () => void;
14
+ disabled?: boolean;
15
+ }) {
16
+ const carousel = useCctvPaginationCarousel({ optional: true });
17
+ const handleMove = useMemo(
18
+ () => onNext ?? carousel?.onNext ?? (() => undefined),
19
+ [carousel?.onNext, onNext],
20
+ );
21
+ const isDisabled =
22
+ typeof disabled === "boolean" ? disabled : Boolean(carousel?.isReachEnd);
23
+
24
+ // Carousel Provider 존재 시 컨텍스트 제어를 사용하고, 없으면 props로 받은 핸들러를 사용한다.
25
+ return (
26
+ <CCTVPaginationBaseButton
27
+ direction="next"
28
+ onMove={handleMove}
29
+ disabled={isDisabled}
30
+ >
31
+ {children}
32
+ </CCTVPaginationBaseButton>
33
+ );
34
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import CCTVPaginationBaseButton from "./Base";
5
+ import { useCctvPaginationCarousel } from "../Provider";
6
+
7
+ export default function CCTVPaginationPrevButton({
8
+ children,
9
+ onPrev,
10
+ disabled,
11
+ }: {
12
+ children?: React.ReactNode;
13
+ onPrev?: () => void;
14
+ disabled?: boolean;
15
+ }) {
16
+ const carousel = useCctvPaginationCarousel({ optional: true });
17
+ const handleMove = useMemo(
18
+ () => onPrev ?? carousel?.onPrev ?? (() => undefined),
19
+ [carousel?.onPrev, onPrev],
20
+ );
21
+ const isDisabled =
22
+ typeof disabled === "boolean" ? disabled : Boolean(carousel?.isReachStart);
23
+
24
+ // Carousel Provider 존재 시 컨텍스트 제어를 사용하고, 없으면 props로 받은 핸들러를 사용한다.
25
+ return (
26
+ <CCTVPaginationBaseButton
27
+ direction="prev"
28
+ onMove={handleMove}
29
+ disabled={isDisabled}
30
+ >
31
+ {children}
32
+ </CCTVPaginationBaseButton>
33
+ );
34
+ }
@@ -0,0 +1,25 @@
1
+ import CCTVPaginationBaseButton from "./buttons/Base";
2
+ import CCTVPaginationNextButton from "./buttons/Next";
3
+ import CCTVPaginationPrevButton from "./buttons/Prev";
4
+ import CCTVPaginationContainer from "./Container";
5
+ import CCTVPaginationControl from "./Control";
6
+ import CCTVPaginationListCarousel from "./list/Carousel";
7
+ import CCTVPaginationListContainer from "./list/Container";
8
+ import CCTVPaginationListItem from "./list/Item";
9
+ import { CctvPaginationCarouselProvider } from "./Provider";
10
+
11
+ export const CCTVPagination = {
12
+ Container: CCTVPaginationContainer,
13
+ Control: CCTVPaginationControl,
14
+ List: {
15
+ Provider: CctvPaginationCarouselProvider,
16
+ Container: CCTVPaginationListContainer,
17
+ Carousel: CCTVPaginationListCarousel,
18
+ Item: CCTVPaginationListItem,
19
+ },
20
+ Button: {
21
+ Base: CCTVPaginationBaseButton,
22
+ Prev: CCTVPaginationPrevButton,
23
+ Next: CCTVPaginationNextButton,
24
+ },
25
+ };
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import { Children, useEffect } from "react";
4
+ import { useCctvPaginationCarousel } from "../Provider";
5
+
6
+ export default function CCTVPaginationListCarousel({
7
+ children,
8
+ }: {
9
+ children: React.ReactNode;
10
+ }) {
11
+ const carousel = useCctvPaginationCarousel();
12
+ const childArray = Children.toArray(children);
13
+ const itemCount = childArray.length;
14
+
15
+ useEffect(() => {
16
+ carousel?.registerItemCount(itemCount);
17
+ }, [carousel, itemCount]);
18
+
19
+ return (
20
+ <div className="cctv-pagination-viewport" ref={carousel?.viewportRef}>
21
+ <ul className="cctv-pagination-track" ref={carousel?.trackRef}>
22
+ {childArray}
23
+ </ul>
24
+ </div>
25
+ );
26
+ }