언어/Next.js

[Next 고캠핑] 모달 만들기

홍시_코딩기록 2024. 7. 31. 22:44

 

 

캠핑장 상세페이지, 이미지 모달창

지금 캠핑 상세 화면의 이미지만 먼저 작업중에 있다. 아직 이미지 외의 다른 데이터는 작업 x

 

이미지를 누르면 전체 사진을 보여주려 한다. 우선 모달창만 구현한 화면.

 

component/Modal

interface IPropsModal {
  isShowing: boolean;
  hide: () => void;
  message: string;
}

export function Modal({ isShowing, hide, message }: IPropsModal) {
  return isShowing
    ? ReactDOM.createPortal(
        <Wrap onClick={hide}>
          <ModalInner
            className="modal"
            onClick={(e: React.MouseEvent<HTMLDivElement>) => {
              e.stopPropagation();
            }}
          >
            <div>
              <AlertIcon />
              <p>{message}</p>
            </div>
            <Button onClick={hide}>닫기</Button>
          </ModalInner>
        </Wrap>,
        document.body,
      )
    : null;
}

모달창을 따로 만들 필요는 없을 것 같아서 전에 alert창 구현으로 만들어놓은 Modal을 이용하려 한다.

 

 


const ModalInner = styled.div<{ customStyle?: React.CSSProperties }>`
  padding: 3rem 4rem 2rem;
  min-width: 40rem;
  min-height: 20rem;
  margin: auto;
  background-color: white;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  border-radius: 2rem;
  text-align: center;
  box-shadow: 0 2px 30px 0 rgba(0, 0, 0, 0.2);
  ${({ customStyle }) => customStyle && { ...customStyle }}

  @media ${responsive.mobile} {
    width: calc(100% - 3.2rem);
  }

  p {
    margin-top: 1rem;
  }
`;

const AlertIcon = styled(IoAlertCircleOutline)`
  width: 4.4rem;
  height: 4.4rem;
  stroke: #67794a;
  fill: #67794a;
`;
interface IPropsModal {
  isShowing: boolean;
  hide: () => void;
  message?: string;
  children?: ReactNode;
  customStyle?: React.CSSProperties;
}

export function Modal({
  isShowing,
  hide,
  message,
  children,
  customStyle,
}: IPropsModal) {
  return isShowing
    ? ReactDOM.createPortal(
        <Wrap onClick={hide}>
          <ModalInner
            customStyle={customStyle}
            onClick={(e: React.MouseEvent<HTMLDivElement>) => {
              e.stopPropagation();
            }}
          >
            {message ? (
              <div>
                <AlertIcon />
                <p>{message}</p>
              </div>
            ) : (
              children
            )}
            <Button onClick={hide}>닫기</Button>
          </ModalInner>
        </Wrap>,
        document.body,
      )
    : null;
}

alert일 때는 메세지만 들어가면 되니까  alert창과 modal창일 때로 구분을 해준다.

모달창일 때는 닫기 버튼을 우측 상단으로 바꿀 것 같은데 일단 확인용으로 진행.

ModalInner는 모달창마다 디자인이 다를 수도 있으니 스타일을 다르게 주는 것이 필요했다.

스타일을 넘겨주려면 React.CSSProperties 타입을 사용하면 된다고 한다.

 

components/modal/imageDetailModal.tsx

import styled from "@emotion/styled";
import { Modal } from "./";

const customStyle = {
  backgroundColor: "blue",
};

const Wrap = styled.div`
  background: red;
  width: 100%;
  height: 100%;
`;
interface IPropsModal {
  isShowing: boolean;
  hide: () => void;
}
export default function ImageDetailModal({ isShowing, hide }: IPropsModal) {
  return (
    <Modal isShowing={isShowing} hide={hide} customStyle={customStyle}>
      <Wrap>이미지 모달이다 모달이야 모달요</Wrap>
    </Modal>
  );
}

모달창 안에 들어갈 내용을 넣어준다.

customStyle로 스타일도 넣어줌.

 

 

components/campingDetail/imageDetail/index.tsx

interface IImageList {
  serialNum: number;
  imageUrl: string;
}

interface IApiResponse {
  response: {
    body: {
      items: {
        item: IImageList[];
      };
    };
  };
}

interface IPropsImageDetail {
  onClick?: () => void;
}

export default function ImageDetail({ onClick }: IPropsImageDetail) {
  const [imageUrl, setImageUrl] = useState<IImageList[]>([]);
  const router = useRouter();
  const contentId = Number(router.query.contentId);

  const SERVICE_KEY = process.env.NEXT_PUBLIC_SERVICE_KEY;

  useEffect(() => {
    async function fetchData(): Promise<void> {
      if (!contentId) return;

      try {
        const response = await axios.get<IApiResponse>(
          `http://apis.data.go.kr/B551011/GoCamping/imageList?serviceKey=${SERVICE_KEY}&MobileOS=ETC&MobileApp=dayCamping&contentId=${contentId}&numOfRows=30&_type=json`,
        );
        setImageUrl(response.data.response?.body?.items.item);
      } catch (e) {
        console.error(e);
      }
    }
    void fetchData();
  }, [contentId, SERVICE_KEY]);

  const campingImage = imageUrl.slice(1, 6); // 1번 이미지는 정보 이미지 같아서 우선 제외
  return (
    <>
      <ImgWrap>
        <div className="left">
          <div onClick={onClick} className="img">
            <img src={campingImage[0]?.imageUrl} alt="캠핑이미지" />
          </div>
        </div>
        <div className="right">
          <div>
            <div onClick={onClick} className="img">
              <img src={campingImage[1]?.imageUrl} alt="캠핑이미지" />
            </div>
            <div onClick={onClick} className="img">
              <img src={campingImage[2]?.imageUrl} alt="캠핑이미지" />
            </div>
          </div>
          <div>
            <div onClick={onClick} className="img">
              <img src={campingImage[3]?.imageUrl} alt="캠핑이미지" />
            </div>
            <div onClick={onClick} className="img">
              <img src={campingImage[4]?.imageUrl} alt="캠핑이미지" />
            </div>
          </div>
        </div>
        <button onClick={onClick} className="all_view">
          전체 사진 보기
        </button>
      </ImgWrap>
    </>
  );
}

캠핑 카드 클릭시 해당하는 캠핑장의 contentId를 쿼리로 받아와서 contentId로 선언해준다(숫자로 변환)

고캠핑 활용 메뉴얼

 

이미지 정보 목록 api로 가져왔다.

캠핑장 상세페이지엔 이미지 5개만 필요해서 slice로 5개를 잘랐는데 첫번째 이미지는 정보 이미지 같아서 우선 제외했다.

근데 다 그런건 아니고 캠핑장마다 달라서 어떤 기준인지는 확인이 필요해보인다.

이미지 개수가 10개로 되어있고 총 개수는 캠핑장마다 다르다.

파라미터엔 없지만 기본목록처럼 numOfRows를 추가해서 받아올 수 있지 않을까 해서 넣어봤더니 된다.

contentId 뒤에 넣어주었다.

타입은 json 타입으로, 이미지는 최대 30개 정도만,

이미지와 버튼 온클릭 이벤트를 만들어서 넘겨주었다.

 

 

캠핑장 검색 화면 

 

components/campingList/campingCardList/index.tsx

export default function CampingCardList({ className }: IPropsList) {
....


  const onClickCard = (item: ICampingList) => {
    const { contentId } = item;
    void router.push(`/campingDetail?contentId=${contentId}`);
  };
  
  ...
  
  return (
    <>
      <Wrap>
        {loading ? (
          <Loading />
        ) : list.length > 0 ? (
          <>
            <CampingCard className="card" list={list} onClick={onClickCard} />
            {pageCount > 0 && (
              <Pagination
                totalItems={totalCount}
                onClick={onClickPage}
                currentPage={currentPage}
                pageCount={5}
                itemCountPerPage={PER_PAGE}
              />
            )}
          </>
        ) : (
          <NoData />
        )}
      </Wrap>
    </>
  );