diff --git a/apps/cs/src/pages/banner/model/banner-table-columns.tsx b/apps/cs/src/pages/banner/model/banner-table-columns.tsx index 3d993f766..65c8c6e87 100644 --- a/apps/cs/src/pages/banner/model/banner-table-columns.tsx +++ b/apps/cs/src/pages/banner/model/banner-table-columns.tsx @@ -9,7 +9,9 @@ import { AttachmentApi } from "@/entities/attachment"; import { ENDPOINT } from "@/shared/config"; import type { Banner } from "./banner-interface"; -export const columns: ColumnDef[] = [ +export const columns = ( + imageRefs: React.MutableRefObject>, +): ColumnDef[] => [ { id: "select", header: ({ table }) => ( @@ -33,7 +35,13 @@ export const columns: ColumnDef[] = [ }, { header: "배너 이미지", - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { accessorKey: "bnrTtl", @@ -61,11 +69,23 @@ export const columns: ColumnDef[] = [ }, ]; -const BannerImage = ({ atflCd }: { atflCd: string }) => { +type BannerImageProps = { + atflCd: string; + bnrSn: number; + imageRefs: React.MutableRefObject>; +}; + +const BannerImage = ({ atflCd, bnrSn, imageRefs }: BannerImageProps) => { + const prevImage = imageRefs.current.get(bnrSn); + const { data: imageUrl, isLoading } = useQuery({ queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), - enabled: !!atflCd, + select: (url) => { + if (url) imageRefs.current.set(bnrSn, url); + return url; + }, + enabled: !prevImage && !!atflCd, }); if (isLoading) return ; diff --git a/apps/cs/src/pages/banner/ui/banner-page.tsx b/apps/cs/src/pages/banner/ui/banner-page.tsx index 2f8555fd5..0efc89602 100644 --- a/apps/cs/src/pages/banner/ui/banner-page.tsx +++ b/apps/cs/src/pages/banner/ui/banner-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { @@ -15,6 +15,7 @@ import { ENDPOINT } from "@/shared/config"; import { BannerApi } from "../api/banner-service"; import { columns } from "../model/banner-table-columns"; import { type Banner, type BannerParams } from "../model/banner-interface"; +import { PreviewBannerDialog } from "./preview-banner-dialog"; type PostingTarget = { pstgTrgtNm: string; @@ -23,6 +24,7 @@ type PostingTarget = { function BannerPage() { const router = useRouter(); + const imageRefs = useRef>(new Map()); const [rowSelection, setRowSelection] = useState({}); const [postingTargets, setPostingTargets] = useState([]); @@ -45,7 +47,7 @@ function BannerPage() { const table = useReactTable({ data: bannerList?.content ?? [], - columns, + columns: columns(imageRefs), state: { pagination, rowSelection, @@ -67,6 +69,15 @@ function BannerPage() { router.push(`/banner/register/${row.bnrSn}`); }; + const getSelectedBannerImages = () => { + const selectedImages: string[] = []; + table.getFilteredSelectedRowModel().rows.forEach((row) => { + const imageUrl = imageRefs.current.get(row.original.bnrSn); + if (imageUrl) selectedImages.push(imageUrl); + }); + return selectedImages; + }; + useEffect(() => { const postingTargets = table .getFilteredSelectedRowModel() @@ -115,6 +126,7 @@ function BannerPage() {

배너 목록

+ + + + + + + 배너 이미지 미리보기 + +
+ + + {images.map((image, index) => ( + +
+ {`배너 +
+
+ ))} +
+ + +
+
+
+ + ); +} + +export { PreviewBannerDialog }; diff --git a/packages/common/components/package.json b/packages/common/components/package.json index 8b9b12eed..cab68595e 100644 --- a/packages/common/components/package.json +++ b/packages/common/components/package.json @@ -54,6 +54,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "react-chartjs-2": "^5.3.0", diff --git a/packages/common/components/src/ui/carousel/index.tsx b/packages/common/components/src/ui/carousel/index.tsx new file mode 100644 index 000000000..862e3f440 --- /dev/null +++ b/packages/common/components/src/ui/carousel/index.tsx @@ -0,0 +1,261 @@ +"use client"; + +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref, + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return; + } + + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) { + return; + } + + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) { + return; + } + + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); + }, +); +Carousel.displayName = "Carousel"; + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +}); +CarouselContent.displayName = "CarouselContent"; + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( +
+ ); +}); +CarouselItem.displayName = "CarouselItem"; + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +}); +CarouselPrevious.displayName = "CarouselPrevious"; + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +}); +CarouselNext.displayName = "CarouselNext"; + +export { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +}; diff --git a/packages/common/components/src/ui/index.ts b/packages/common/components/src/ui/index.ts index bea09fa74..134b76d21 100644 --- a/packages/common/components/src/ui/index.ts +++ b/packages/common/components/src/ui/index.ts @@ -2,6 +2,7 @@ export * from "./accordion"; export * from "./badge"; export * from "./button"; export * from "./calendar"; +export * from "./carousel"; export * from "./checkbox"; export * from "./data-table"; export * from "./date-picker"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb0c1aea..f22abbbfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@18.3.1) js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -4821,6 +4824,28 @@ packages: resolution: {integrity: sha512-dJr1o6yCntRkXElnhsHh1bAV19bo/hKyFf7tCcWgpXbuFIF0Lakjgqv5LRfSDaNzAII8Fnxg2tqgHkgCvxdbxw==} dev: true + /embla-carousel-react@8.6.0(react@18.3.1): + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^18.3.1 + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 18.3.1 + dev: false + + /embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + dependencies: + embla-carousel: 8.6.0 + dev: false + + /embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + dev: false + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}