@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.
- package/dist/styles.css +2 -2
- package/package.json +8 -4
- package/src/auth/login/jotai/user.ts +13 -0
- package/src/auth/login/types/api.ts +229 -0
- package/src/auth/login/types/form.ts +1 -0
- package/src/auth/login/types/index.ts +4 -0
- package/src/auth/signup/markup/VerificationForm.tsx +3 -2
- package/src/cctv/apis/client.ts +61 -0
- package/src/cctv/apis/index.ts +2 -0
- package/src/cctv/apis/server.ts +188 -0
- package/src/cctv/components/Provider.tsx +47 -0
- package/src/cctv/components/__viewer.tsx +99 -0
- package/src/cctv/components/cam-list/Container.tsx +36 -0
- package/src/cctv/components/cam-list/Item.tsx +71 -0
- package/src/cctv/components/cam-list/index.tsx +7 -0
- package/src/cctv/components/index.tsx +13 -0
- package/src/cctv/components/pagination/Container.tsx +26 -0
- package/src/cctv/components/pagination/Control.tsx +29 -0
- package/src/cctv/components/pagination/Provider.tsx +204 -0
- package/src/cctv/components/pagination/buttons/Base.tsx +56 -0
- package/src/cctv/components/pagination/buttons/Next.tsx +34 -0
- package/src/cctv/components/pagination/buttons/Prev.tsx +34 -0
- package/src/cctv/components/pagination/index.tsx +25 -0
- package/src/cctv/components/pagination/list/Carousel.tsx +26 -0
- package/src/cctv/components/pagination/list/Container.tsx +30 -0
- package/src/cctv/components/pagination/list/Item.tsx +81 -0
- package/src/cctv/components/video/Container.tsx +13 -0
- package/src/cctv/components/video/Video.tsx +34 -0
- package/src/cctv/components/video/index.tsx +9 -0
- package/src/cctv/components/video/overlay/Container.tsx +15 -0
- package/src/cctv/components/video/overlay/Title.tsx +28 -0
- package/src/cctv/components/video/overlay/body/Container.tsx +13 -0
- package/src/cctv/components/video/overlay/body/Error.tsx +16 -0
- package/src/cctv/components/video/overlay/footer/Container.tsx +30 -0
- package/src/cctv/components/video/overlay/footer/OpenButton.tsx +19 -0
- package/src/cctv/components/video/overlay/header/CloseButton.tsx +14 -0
- package/src/cctv/components/video/overlay/header/Container.tsx +50 -0
- package/src/cctv/components/video/overlay/header/LiveState.tsx +21 -0
- package/src/cctv/components/video/overlay/index.tsx +24 -0
- package/src/cctv/components/viewer/Container.tsx +13 -0
- package/src/cctv/components/viewer/desktop/Container.tsx +38 -0
- package/src/cctv/components/viewer/desktop/Pagination.tsx +20 -0
- package/src/cctv/components/viewer/desktop/Placeholder.tsx +18 -0
- package/src/cctv/components/viewer/desktop/Video.tsx +83 -0
- package/src/cctv/components/viewer/index.tsx +12 -0
- package/src/cctv/components/viewer/mobile/Container.tsx +13 -0
- package/src/cctv/data/context.ts +22 -0
- package/src/cctv/data/index.ts +1 -0
- package/src/cctv/hooks/index.tsx +5 -0
- package/src/cctv/hooks/useCompanyData.tsx +39 -0
- package/src/cctv/hooks/useContext.ts +150 -0
- package/src/cctv/hooks/useRtcStream.ts +94 -0
- package/src/cctv/img/chevron-left.svg +3 -0
- package/src/cctv/img/chevron-right.svg +3 -0
- package/src/cctv/img/error.svg +4 -0
- package/src/cctv/img/viewer-close.svg +3 -0
- package/src/cctv/img/viewer-open.svg +6 -0
- package/src/cctv/index.scss +1 -0
- package/src/cctv/index.tsx +9 -0
- package/src/cctv/jotai/context.ts +9 -0
- package/src/cctv/jotai/index.ts +1 -0
- package/src/cctv/styles/cam-list.scss +32 -0
- package/src/cctv/styles/index.scss +5 -0
- package/src/cctv/styles/pagination.scss +77 -0
- package/src/cctv/styles/variables.scss +38 -0
- package/src/cctv/styles/video.scss +142 -0
- package/src/cctv/styles/viewer.scss +7 -0
- package/src/cctv/types/api.ts +166 -0
- package/src/cctv/types/carousel.ts +24 -0
- package/src/cctv/types/context.ts +68 -0
- package/src/cctv/types/index.ts +4 -0
- package/src/cctv/types/list.ts +94 -0
- package/src/cctv/utils/data.ts +40 -0
- package/src/cctv/utils/select.ts +62 -0
- package/src/index.tsx +3 -0
- package/src/modal/styles/base.scss +2 -2
- package/src/types/api.ts +43 -0
- package/src/types/index.ts +1 -0
- 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,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
|
+
}
|