feat: 배너 이미지 미리보기 다이얼로그 작업

This commit is contained in:
2025-04-08 18:10:27 +09:00
parent 661eb3597a
commit 12a12339b9
7 changed files with 383 additions and 6 deletions
@@ -9,7 +9,9 @@ import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import type { Banner } from "./banner-interface"; import type { Banner } from "./banner-interface";
export const columns: ColumnDef<Banner>[] = [ export const columns = (
imageRefs: React.MutableRefObject<Map<number, string>>,
): ColumnDef<Banner>[] => [
{ {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
@@ -33,7 +35,13 @@ export const columns: ColumnDef<Banner>[] = [
}, },
{ {
header: "배너 이미지", header: "배너 이미지",
cell: ({ row }) => <BannerImage atflCd={row.original.atflCd} />, cell: ({ row }) => (
<BannerImage
atflCd={row.original.atflCd}
bnrSn={row.original.bnrSn}
imageRefs={imageRefs}
/>
),
}, },
{ {
accessorKey: "bnrTtl", 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({ const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => AttachmentApi.getImageUrlByAtflCd(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]" />; if (isLoading) return <Skeleton className="h-[4rem] w-[16rem]" />;
+14 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -15,6 +15,7 @@ import { ENDPOINT } from "@/shared/config";
import { BannerApi } from "../api/banner-service"; import { BannerApi } from "../api/banner-service";
import { columns } from "../model/banner-table-columns"; import { columns } from "../model/banner-table-columns";
import { type Banner, type BannerParams } from "../model/banner-interface"; import { type Banner, type BannerParams } from "../model/banner-interface";
import { PreviewBannerDialog } from "./preview-banner-dialog";
type PostingTarget = { type PostingTarget = {
pstgTrgtNm: string; pstgTrgtNm: string;
@@ -23,6 +24,7 @@ type PostingTarget = {
function BannerPage() { function BannerPage() {
const router = useRouter(); const router = useRouter();
const imageRefs = useRef<Map<number, string>>(new Map());
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]); const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
@@ -45,7 +47,7 @@ function BannerPage() {
const table = useReactTable({ const table = useReactTable({
data: bannerList?.content ?? [], data: bannerList?.content ?? [],
columns, columns: columns(imageRefs),
state: { state: {
pagination, pagination,
rowSelection, rowSelection,
@@ -67,6 +69,15 @@ function BannerPage() {
router.push(`/banner/register/${row.bnrSn}`); 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(() => { useEffect(() => {
const postingTargets = table const postingTargets = table
.getFilteredSelectedRowModel() .getFilteredSelectedRowModel()
@@ -115,6 +126,7 @@ function BannerPage() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">배너 목록</h3> <h3 className="text-2xl font-medium text-label">배너 목록</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<PreviewBannerDialog images={getSelectedBannerImages()} />
<PostingDialog <PostingDialog
postingType={POSTING_TYPE.BANNER} postingType={POSTING_TYPE.BANNER}
postingTargets={postingTargets} 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 };
+1
View File
@@ -54,6 +54,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"react-chartjs-2": "^5.3.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 "./badge";
export * from "./button"; export * from "./button";
export * from "./calendar"; export * from "./calendar";
export * from "./carousel";
export * from "./checkbox"; export * from "./checkbox";
export * from "./data-table"; export * from "./data-table";
export * from "./date-picker"; export * from "./date-picker";
+25
View File
@@ -447,6 +447,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@18.3.1)
js-cookie: js-cookie:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5 version: 3.0.5
@@ -4821,6 +4824,28 @@ packages:
resolution: {integrity: sha512-dJr1o6yCntRkXElnhsHh1bAV19bo/hKyFf7tCcWgpXbuFIF0Lakjgqv5LRfSDaNzAII8Fnxg2tqgHkgCvxdbxw==} resolution: {integrity: sha512-dJr1o6yCntRkXElnhsHh1bAV19bo/hKyFf7tCcWgpXbuFIF0Lakjgqv5LRfSDaNzAII8Fnxg2tqgHkgCvxdbxw==}
dev: true 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: /emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}