feat: 배너 이미지 미리보기 다이얼로그 작업
This commit is contained in:
@@ -9,7 +9,9 @@ import { AttachmentApi } from "@/entities/attachment";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { Banner } from "./banner-interface";
|
||||
|
||||
export const columns: ColumnDef<Banner>[] = [
|
||||
export const columns = (
|
||||
imageRefs: React.MutableRefObject<Map<number, string>>,
|
||||
): ColumnDef<Banner>[] => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
@@ -33,7 +35,13 @@ export const columns: ColumnDef<Banner>[] = [
|
||||
},
|
||||
{
|
||||
header: "배너 이미지",
|
||||
cell: ({ row }) => <BannerImage atflCd={row.original.atflCd} />,
|
||||
cell: ({ row }) => (
|
||||
<BannerImage
|
||||
atflCd={row.original.atflCd}
|
||||
bnrSn={row.original.bnrSn}
|
||||
imageRefs={imageRefs}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "bnrTtl",
|
||||
@@ -61,11 +69,23 @@ export const columns: ColumnDef<Banner>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BannerImage = ({ atflCd }: { atflCd: string }) => {
|
||||
type BannerImageProps = {
|
||||
atflCd: string;
|
||||
bnrSn: number;
|
||||
imageRefs: React.MutableRefObject<Map<number, string>>;
|
||||
};
|
||||
|
||||
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 <Skeleton className="h-[4rem] w-[16rem]" />;
|
||||
|
||||
@@ -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<Map<number, string>>(new Map());
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
|
||||
@@ -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() {
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-2xl font-medium text-label">배너 목록</h3>
|
||||
<div className="flex gap-2">
|
||||
<PreviewBannerDialog images={getSelectedBannerImages()} />
|
||||
<PostingDialog
|
||||
postingType={POSTING_TYPE.BANNER}
|
||||
postingTargets={postingTargets}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@common/components";
|
||||
|
||||
type PreviewBannerDialogProps = {
|
||||
images: string[];
|
||||
};
|
||||
|
||||
function PreviewBannerDialog({ images }: PreviewBannerDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!images.length}>미리보기</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>배너 이미지 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 px-12">
|
||||
<Carousel>
|
||||
<CarouselContent>
|
||||
{images.map((image, index) => (
|
||||
<CarouselItem key={index} className="basis-1/3">
|
||||
<div className="relative flex aspect-video items-center justify-center">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`배너 이미지 ${index + 1}`}
|
||||
fill
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="!bg-accent text-accent-foreground" />
|
||||
<CarouselNext className="!bg-accent text-accent-foreground" />
|
||||
</Carousel>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { PreviewBannerDialog };
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof useEmblaCarousel>;
|
||||
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<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & 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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Generated
+25
@@ -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==}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user