๐ŸŽฏ ๋ฆฌ๋ทฐ, ์‹ ๊ฐ„, ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ, ๋ฐฐ๋„ˆ ๋“ฑ์œผ๋กœ ๋ฉ”์ธ ํ™”๋ฉด์„ ์ œ์ž‘ํ•˜๊ณ , ๋ชจ๋ฐ”์ผ ๋Œ€์‘์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“— Today I Learned

React Slick

React์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์Šฌ๋ผ์ด๋”(์บ๋Ÿฌ์…€) ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค.

์„ค์น˜ ๋ฐฉ๋ฒ•

npm install react-slick slick-carousel

npm install -D @types/react-slick
  • react-slick : React ์Šฌ๋ผ์ด๋” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค.

  • slick-carousel : ์Šฌ๋ผ์ด๋”๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๊ณ  ๊พธ๋ฉฐ์ง€๋„๋ก ํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ๋ณธ ์Šคํƒ€์ผ(CSS)์„ ํฌํ•จํ•˜๊ณ  ์žˆ๋А ์˜์กด์„ฑ ํŒจํ‚ค์ง€์ž…๋‹ˆ๋‹ค. (๋ฐ˜๋“œ์‹œ ์„ค์น˜ ํ•„์š”)

  • @types/react-slick : TypeScript ํ™˜๊ฒฝ์—์„œ react-slick์˜ ํƒ€์ž… ์ฒดํฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ด์ฃผ๋Š” ํƒ€์ž… ์ „์šฉ ๋ณด์กฐ ํŒจํ‚ค์ง€์ž…๋‹ˆ๋‹ค.

๐Ÿค” ์˜์กด์„ฑ ํŒจํ‚ค์ง€์ด์ง€๋งŒ -D๋กœ ์„ค์น˜ํ•˜์ง€ ์•Š๋Š” ์ด์œ ?

devDependencies๋Š” ๋ณดํ†ต ํ”„๋กœ๋•์…˜ ๋นŒ๋“œ์—์„œ๋Š” ์ œ์™ธ๋˜๋Š”๋ฐ, slick-carousel์€ ์‹คํ–‰ ํ™˜๊ฒฝ์—์„œ๋„ ํ•„์š”ํ•œ CSS๋ฅผ ์ œ๊ณตํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ทธ๋ƒฅ ์„ค์น˜๋ฅผ ํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค.


๊ธฐ๋ณธ ์‚ฌ์šฉ ์˜ˆ์‹œ

import React from "react";
import Slider from "react-slick";

import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";

export default function SimpleSlider() {
  var settings = {
    dots: true,
    infinite: true,
    speed: 500,
    slidesToShow: 1,
    slidesToScroll: 1,
  };
  return (
    <Slider {...settings}>
      <div>
        <h3>1</h3>
      </div>
      <div>
        <h3>2</h3>
      </div>
      <div>
        <h3>3</h3>
      </div>
      <div>
        <h3>4</h3>
      </div>
      <div>
        <h3>5</h3>
      </div>
      <div>
        <h3>6</h3>
      </div>
    </Slider>
  );
}
  • settings : ์„ค์ • ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค.

    • dots: true : ํ•˜๋‹จ์— ํŽ˜์ด์ง€ ํ‘œ์‹œ ์  ํ‘œ์‹œ

    • infinite: true : ๋งˆ์ง€๋ง‰ ์Šฌ๋ผ์ด๋“œ์—์„œ ์ฒ˜์Œ์œผ๋กœ ์ˆœํ™˜

    • speed: 500 : ์Šฌ๋ผ์ด๋“œ ๋„˜์–ด๊ฐ€๋Š” ์†๋„ (0.5์ดˆ)

    • slidesToShow: 1 : ํ•œ ๋ฒˆ์— ๋ณด์—ฌ์ค„ ์Šฌ๋ผ์ด๋“œ 1๊ฐœ

    • slidesToScroll: 1 : ๋„˜๊ธธ ๋•Œ ํ•œ ์žฅ์”ฉ ๋„˜๊น€

  • <Slider {...settings}> : ์„ค์ • ๊ฐ์ฒด๋ฅผ props๋กœ ๋„˜๊ฒจ ์Šฌ๋ผ์ด๋”์— ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.




๋ฉ”์ธํŽ˜์ด์ง€

๋ฆฌ๋ทฐ

review.ts

export const reviewForMain = http.get('http://localhost:9999/reviews', () => {
  return HttpResponse.json(mockReviewData, {
    status: 200,
  });
});

๋ฉ”์ธํŽ˜์ด์ง€์šฉ ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์งœ๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ํ—จ๋“ค๋Ÿฌ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • http://localhost:9999/reviews๋กœ GET ์š”์ฒญ์ด ์˜ค๋ฉด, mockReviewData ๋ฐฐ์—ด์„ JSON ํ˜•์‹์œผ๋กœ 200 OK๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ๊ฐ€์งœ API์ž…๋‹ˆ๋‹ค.

review.api.ts

export const fetchReviewAll = async () => {
  return await requestHandler<BookReviewItem[]>('get', `/reviews`);
};

GET /reviews ์š”์ฒญ์„ ๋ณด๋‚ด BookReviewItem[] ํƒ€์ž…์˜ ์ „์ฒด ๋ฆฌ๋ทฐ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.


MainReview.tsx

import { BookReviewItem as IBookReviewItem } from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from '../book/BookReviewItem';
import Slider from 'react-slick';
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';
import { useMediaQuery } from '@/hooks/useMediaQuery';

interface Props {
  reviews: IBookReviewItem[];
}

export default function MainReview({ reviews }: Props) {
  const { isMobile } = useMediaQuery();

  let sliderSettings = {
    dots: true,
    infinite: true,
    speed: 500,
    slidesToShow: isMobile ? 1 : 3,
    slidesToScroll: isMobile ? 1 : 3,
    gap: 16,
  };
  return (
    <StyledMainReview>
      <Slider {...sliderSettings}>
        {reviews.map((review) => (
          <BookReviewItem key={review.id} review={review} />
        ))}
      </Slider>
    </StyledMainReview>
  );
}

const StyledMainReview = styled.div`
  padding: 0 0 24px 0;

  .slick-track {
    padding: 12px 0;
  }

  .slick-slide > div {
    margin: 0 12px;
  }

  .slick-prev:before,
  .slick-next:before {
    color: #000;
  }

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    .slick-prev {
      left: 0;
    }
    .slick-next {
      right: 0;
    }
  }
`;
  • <Slider {...sliderSettings}> : sliderSettings๋ฅผ ๊ทธ๋Œ€๋กœ ๋„˜๊ธด react-slider์˜ ์Šฌ๋ผ์ด๋” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค.

    • reviews ๋ฐฐ์—ด์„ ์ˆœํšŒํ•˜๋ฉด์„œ, ๊ฐ ๋ฆฌ๋ทฐ๋ฅผ BookReviewItem ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ ํ™”๋ฉด




์‹ ๊ฐ„ ์•ˆ๋‚ด

MainNewBook.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem from '../books/BookItem';

interface Props {
  books: Book[];
}

export default function MainNewBooks({ books }: Props) {
  return (
    <StyledMainNewBooks>
      {books.map((book) => (
        <BookItem key={book.id} book={book} view='grid' />
      ))}
    </StyledMainNewBooks>
  );
}

const StyledMainNewBooks = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    grid-template-columns: repeat(2, 1fr);
  }
`;
  • books ๋ฐฐ์—ด์„ ๋Œ๋ฉด์„œ ๊ฐ๊ฐ์„ BookItem ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ ํ™”๋ฉด




๋ฒ ์ŠคํŠธ์…€๋Ÿฌ

book.ts

import { Book } from '@/models/book.model';
import { HttpResponse, http } from 'msw';
import { fakerKO as faker } from '@faker-js/faker';

const bestBooksData: Book[] = Array.from({ length: 10 }).map((_, index) => ({
  id: index,
  title: faker.lorem.sentence(),
  img: faker.number.int({ min: 100, max: 200 }),
  category_id: faker.number.int({ min: 0, max: 2 }),
  form: '์ข…์ด์ฑ…',
  isbn: faker.commerce.isbn(),
  summary: faker.lorem.paragraph(),
  detail: faker.lorem.paragraph(),
  author: faker.person.firstName(),
  pages: faker.number.int({ min: 100, max: 500 }),
  contents: faker.lorem.paragraph(),
  price: faker.number.int({ min: 10000, max: 50000 }),
  likes: faker.number.int({ min: 0, max: 100 }),
  pubDate: faker.date.past().toISOString(),
}));

export const bestBooks = http.get('http://localhost:9999/books/best', () => {
  return HttpResponse.json(bestBooksData, {
    status: 200,
  });
});

๋ฒ ์ŠคํŠธ์…€๋Ÿฌ ๋„์„œ ๋ชฉ๋ก์„ ๊ฐ€์งœ๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ํ—จ๋“ค๋Ÿฌ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • http://localhost:9999/books/best๋กœ GET ์š”์ฒญ์ด ์˜ค๋ฉด, bestBooksData ๋ฐฐ์—ด์„ JSON ํ˜•์‹์œผ๋กœ 200 OK๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ๊ฐ€์งœ API์ž…๋‹ˆ๋‹ค.

book.api.ts

export const fetchBestBooks = async () => {
  const response = await httpClient.get<Book[]>(`/books/best`);
  return response.data;
};

GET /books/best ์š”์ฒญ์„ ๋ณด๋‚ด Book[] ํƒ€์ž…์˜ ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.


MainBest.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookBestItem from '../books/BookBestItem';

interface Props {
  books: Book[];
}

export default function MainBest({ books }: Props) {
  return (
    <StyledMainBest>
      {books.map((item, index) => (
        <BookBestItem key={item.id} book={item} itemIndex={index} />
      ))}
    </StyledMainBest>
  );
}

const StyledMainBest = styled.div`
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 16px;

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    grid-template-columns: repeat(2, 1fr);
  }
`;
  • books ๋ฐฐ์—ด์„ ๋Œ๋ฉด์„œ ํ•˜๋‚˜์”ฉ BookBestItem ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

BookBestItem.tsx

import { Book } from '@/models/book.model';
import styled from 'styled-components';
import BookItem, { StyledBookItem } from './BookItem';
import { Theme } from '@/style/theme';

interface Props {
  book: Book;
  itemIndex: number;
}

export default function BookBestItem({ book, itemIndex }: Props) {
  return (
    <StyledBookBestItem>
      <BookItem book={book} view='grid' />
      <div className='rank'>{itemIndex + 1}</div>
    </StyledBookBestItem>
  );
}

const StyledBookBestItem = styled.div`
  ${StyledBookItem} {
    .summary,
    .price,
    .likes {
      display: none;
    }

    h2 {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      text-overflow: ellipsis;
    }
  }

  position: relative;

  .rank {
    position: absolute;
    top: -10px;
    left: -10px;
    width: 40px;
    height: 40px;
    background: ${({ theme }) => (theme as Theme).color.primary};
    border-radius: 500px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.25rem;
    color: #fff;
    font-weight: 700;
    font-style: italic;
  }
`;
  • <BookItem> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ์ˆœ์œ„ ๋ฑƒ์ง€๋ฅผ ์˜ค๋ฒ„๋ ˆ์ด๋กœ ๊ฒน์ณ์„œ ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ๋ฅผ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ ํ™”๋ฉด




๋ฐฐ๋„ˆ

banner.model.ts

export interface Banner {
  id: number;
  title: string;
  description: string;
  image: string;
  url: string;
  target: string;
}

๋ฐฐ๋„ˆ ๋ฐ์ดํ„ฐ ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

  • id : ๋ฐฐ๋„ˆ ๊ณ ์œ  ID

  • title : ๋ฐฐ๋„ˆ ์ œ๋ชฉ (ex: '์‹ ๊ฐ„ ์ถœ์‹œ!')

  • description : ๋ถ€์ œ๋ชฉ, ์„ค๋ช… ๋ฌธ๊ตฌ

  • image : ์ด๋ฏธ์ง€ URL

  • url : ๋ฐฐ๋„ˆ ํด๋ฆญ ์‹œ ์ด๋™ํ•  ๋งํฌ

  • target : ๋งํฌ ์—ด๊ธฐ ๋ฐฉ์‹ ('_blank'(์ƒˆ ํƒญ์—์„œ ์—ด๊ธฐ), '_self'(ํ˜„์žฌ ํƒญ์—์„œ ์—ด๊ธฐ) ๋“ฑ)


banner.ts

import { HttpResponse, http } from 'msw';
import { Banner } from '@/models/banner.model';

const bannerData: Banner[] = [
  {
    id: 1,
    title: '์‹ ๊ฐ„ ์ถœ์‹œ: ์•„์ฃผ ์ž‘์€ ์Šต๊ด€์˜ ํž˜',
    description: '๋” ๋‚˜์€ ์Šต๊ด€์œผ๋กœ ์‚ถ์„ ๋ฐ”๊ฟ”๋ณด์„ธ์š”. ์ง€๊ธˆ ์‹œ์ž‘ํ•˜์„ธ์š”.',
    image: 'http://picsum.photos/id/111/1200/400',
    url: 'http://some.url',
    target: '_blank',
  },
  {
    id: 2,
    title: '๋ฌด๋ผ์นด๋ฏธ ํ•˜๋ฃจํ‚ค ์‹ ์ž‘',
    description: '๋ฌด๋ผ์นด๋ฏธ์˜ ๋ชฝํ™˜์ ์ธ ์„ธ๊ณ„๋ฅผ ์ง€๊ธˆ ๋ฐ”๋กœ ๊ฒฝํ—˜ํ•ด๋ณด์„ธ์š”.',
    image: 'http://picsum.photos/id/222/1200/400',
    url: 'http://some.url',
    target: '_self',
  },
  {
    id: 3,
    title: '๋ด„๋งž์ด ๋„์„œ ํ• ์ธ!',
    description: '์„ ํƒ๋œ ์†Œ์„ค ์ตœ๋Œ€ 40% ํ• ์ธ. ๊ธฐ๊ฐ„ ํ•œ์ •!',
    image: 'http://picsum.photos/id/33/1200/400',
    url: 'http://some.url',
    target: '_blank',
  },
];

export const banners = http.get('http://localhost:9999/banners', () => {
  return HttpResponse.json(bannerData, {
    status: 200,
  });
});

๋ฒ ๋„ˆ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์งœ๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ํ—จ๋“ค๋Ÿฌ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • http://localhost:9999/banners๋กœ GET ์š”์ฒญ์ด ์˜ค๋ฉด, bannerData ๋ฐฐ์—ด์„ JSON ํ˜•์‹์œผ๋กœ 200 OK๋กœ ์‘๋‹ตํ•ด์ฃผ๋Š” ๊ฐ€์งœ API์ž…๋‹ˆ๋‹ค.

browser.ts

import { setupWorker } from 'msw/browser';
import { addReview, reviewForMain, reviewsById } from './review';
import { bestBooks } from './books';
import { banners } from './banner';

const handlers = [reviewsById, addReview, reviewForMain, bestBooks, banners];

export const worker = setupWorker(...handlers);

๋ฆฌ๋ทฐ, ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ, ๋ฐฐ๋„ˆ(review, books, banner) ๊ฐ€์งœ API ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋ชจ์•„์„œ Service Worker๋ฅผ ๋“ฑ๋กํ•˜์—ฌ ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ•๋‹ˆ๋‹ค.


banner.api.ts

import { Banner } from '@/models/banner.model';
import { requestHandler } from './http';

export const fetchBanners = async () => {
  return await requestHandler<Banner[]>('get', '/banners');
};

GET /banners ์š”์ฒญ์„ ๋ณด๋‚ด Banner[] ํƒ€์ž…์˜ ๋ฐฐ๋„ˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ›์•„์˜ค๋Š” ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.


Banner.tsx

import { Banner as IBanner } from '@/models/banner.model';
import styled from 'styled-components';
import BannerItem from './BannerItem';
import { useMemo, useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { Theme } from '@/style/theme';

interface Props {
  banners: IBanner[];
}

export default function Banner({ banners }: Props) {
  const [currentIndex, setCurrentIndex] = useState(0);

  const transFormValue = useMemo(() => {
    return currentIndex * -100;
  }, [currentIndex]);

  const handlePrev = () => {
    if (currentIndex === 0) return;
    setCurrentIndex(currentIndex - 1);
  };
  const handleNext = () => {
    if (currentIndex === banners.length - 1) return;
    setCurrentIndex(currentIndex + 1);
  };

  const handleIndicatorClick = (index: number) => {
    setCurrentIndex(index);
  };

  return (
    <StyledBanner>
      <StyledBannerContainer $transFormValue={transFormValue}>
        {banners.map((item, index) => (
          <BannerItem banner={item} />
        ))}
      </StyledBannerContainer>

      <StyledBannerButton>
        <button className='prev' onClick={handlePrev}>
          <FaAngleLeft />
        </button>
        <button className='next' onClick={handleNext}>
          <FaAngleRight />
        </button>
      </StyledBannerButton>
      <StyledBannerIndicator>
        {banners.map((banner, index) => (
          <span
            className={index === currentIndex ? 'active' : ''}
            onClick={() => handleIndicatorClick(index)}
          ></span>
        ))}
      </StyledBannerIndicator>
    </StyledBanner>
  );
}

const StyledBanner = styled.div`
  overflow: hidden;
  position: relative;
`;

interface StyledBannerContainerProps {
  $transFormValue: number;
}

const StyledBannerContainer = styled.div<StyledBannerContainerProps>`
  display: flex;
  transform: translateX(${(props) => props.$transFormValue}%);
  transition: transform 0.5s ease-in-out;
`;

const StyledBannerButton = styled.div`
  button {
    border: 0;
    width: 40px;
    height: 40px;
    background: rgba(0, 0, 0, 0.5);
    border-radius: 500px;
    font-size: 2rem;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);

    svg {
      fill: #fff;
    }

    &.prev {
      left: 10px;
    }

    &.next {
      right: 10px;
    }

    @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
      width: 28px;
      height: 28px;
      font-size: 1.5rem;

      &.prev {
        left: 0px;
      }

      &.next {
        right: 0px;
      }
    }
  }
`;

const StyledBannerIndicator = styled.div`
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  span {
    display: inline-block;
    width: 16px;
    height: 16px;
    border-radius: 100px;
    background: #fff;
    margin: 0 4px;
    cursor: pointer;

    &.active {
      background: ${({ theme }) => (theme as Theme).color.primary};
    }
  }

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    bottom: 0;
    span {
      width: 12px;
      height: 12px;

      &.active {
        width: 24px;
      }
    }
  }
`;
  • <StyledBanner> : banners๋ฅผ ๋Œ๋ฉด์„œ ํ•˜๋‚˜์”ฉ BannerItem ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ขŒ์šฐ๋กœ ์Šฌ๋ผ์ด๋”ฉ ์—ฐ์ถœ์„ ํ•ฉ๋‹ˆ๋‹ค.

  • <StyledBannerButton> : ์ด์ „/๋‹ค์Œ ๋ฒ„ํŠผ ๋ˆ„๋ฅด๋ฉด currentIndex ์กฐ์ •๋˜์–ด ๋ณด์—ฌ์ง€๋Š” ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.

  • <StyledBannerIndicator> : ํ•˜๋‹จ ๋™๊ทธ๋ž€ ์ธ๋””์ผ€์ดํ„ฐ๋กœ ํ˜„์žฌ ์Šฌ๋ผ์ด๋“œ์˜ index์™€ ๊ฐ™์œผ๋ฉด .active ํด๋ž˜์Šค๋ฅผ ์ ์šฉ์‹œํ‚ค๋ฉด์„œ ํ•ด๋‹น ์ธ๋ฑ์Šค๋กœ ์Šฌ๋ผ์ด๋“œ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.


BannerItem.tsx

import { Banner as IBanner } from '@/models/banner.model';
import styled from 'styled-components';

interface Props {
  banner: IBanner;
}

export default function BannerItem({ banner }: Props) {
  return (
    <StyledBannerItem>
      <div className='img'>
        <img src={banner.image} alt={banner.title} />
      </div>
      <div className='content'>
        <h2>{banner.title}</h2>
        <p>{banner.description}</p>
      </div>
    </StyledBannerItem>
  );
}

const StyledBannerItem = styled.div`
  flex: 0 0 100%;

  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  position: relative;

  .img {
    img {
      width: 100%;
      max-width: 100%;
    }
  }

  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 60%;
    height: 100%;
    background: linear-gradient(
      to right,
      rgba(255, 255, 255, 1),
      rgba(255, 255, 255, 0)
    );

    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    h2 {
      font-size: 2rem;
      font-weight: 700;
      margin-bottom: 1rem;
      color: ${({ theme }) => theme.color.primary};
    }

    p {
      font-size: 1.2rem;
      color: ${({ theme }) => theme.color.text};
      margin: 0;
    }
  }

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    .content {
      width: 100%;
      background: linear-gradient(
        to top,
        rgba(255, 255, 255, 1),
        rgba(255, 255, 255, 0)
      );

      h2 {
        font-size: 1.5rem;
        margin: 8px;
      }

      p {
        font-size: 0.75rem;
      }
    }
  }
`;
  • ํ•˜๋‚˜์”ฉ ๋ฐฐ๋„ˆ ์ด๋ฏธ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๋ฉด์„œ, ํ…์ŠคํŠธ๋ฅผ ์˜ค๋ฒ„๋ ˆ์ด๋กœ ๋ฎ์€ ์Šฌ๋ผ์ด๋“œ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค.

useMain.ts

import { fetchBanners } from '@/api/banner.api';
import { fetchBestBooks, fetchBooks } from '@/api/books.api';
import { fetchReviewAll } from '@/api/review.api';
import { Banner } from '@/models/banner.model';
import { Book, BookReviewItem } from '@/models/book.model';
import { useEffect, useState } from 'react';

export const useMain = () => {
  const [reviews, setReviews] = useState<BookReviewItem[]>([]);
  const [newBooks, setNewBooks] = useState<Book[]>([]);
  const [bestBooks, setBestBooks] = useState<Book[]>([]);
  const [banners, setBanners] = useState<Banner[]>([]);

  useEffect(() => {
    fetchReviewAll().then((reviews) => {
      setReviews(reviews);
    });

    fetchBooks({
      category_id: undefined,
      news: true,
      currentPage: 1,
      limit: 4,
    }).then(({ books }) => {
      setNewBooks(books);
    });

    fetchBestBooks().then((books) => setBestBooks(books));

    fetchBanners().then((banners) => setBanners(banners));
  }, []);

  return { reviews, newBooks, bestBooks, banners };
};

๋ฉ”์ธ ํŽ˜์ด์ง€์— ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ ๋ฆฌ๋ทฐ, ์‹ ๊ฐ„, ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ, ๋ฐฐ๋„ˆ๋ฅผ useEffect ํ•œ ๋ฒˆ์œผ๋กœ ๋ถˆ๋Ÿฌ์˜ค๊ณ , ๊ฐ๊ฐ useState์— ์ €์žฅํ•ด์„œ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‰ฝ๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์ฃผ๋Š” ๋ฉ”์ธ ํŽ˜์ด์ง€ ์ปค์Šคํ…€ ํ›…์ž…๋‹ˆ๋‹ค.

Home.tsx

import Title from '@/components/common/Title';
import MainNewBooks from '@/components/main/MainNewBooks';
import { useMain } from '@/hooks/useMain';
import styled from 'styled-components';
import MainReview from '@/components/main/MainReview';
import MainBest from '@/components/main/MainBest';
import Banner from '@/components/common/banner/Banner';

export default function Home() {
  const { reviews, newBooks, bestBooks, banners } = useMain();

  return (
    <StyledHome>
      <Banner banners={banners} />
      <section className='section'>
        <Title size='large'>๋ฒ ์ŠคํŠธ ์…€๋Ÿฌ</Title>
        <MainBest books={bestBooks} />
      </section>
      <section className='section'>
        <Title size='large'>์‹ ๊ฐ„ ์•ˆ๋‚ด</Title>
        <MainNewBooks books={newBooks} />
      </section>
      <section className='section'>
        <Title size='large'>๋ฆฌ๋ทฐ</Title>
        <MainReview reviews={reviews} />
      </section>
    </StyledHome>
  );
}

const StyledHome = styled.div`
  display: flex;
  flex-direction: column;
  gap: 24px;
`;

๋ฐฐ๋„ˆ, ๋ฒ ์ŠคํŠธ์…€๋Ÿฌ, ์‹ ๊ฐ„, ๋ฆฌ๋ทฐ๋กœ ํ™ˆ ํ™”๋ฉด์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.


๊ฒฐ๊ณผ ํ™”๋ฉด




๋ชจ๋ฐ”์ผ ๋Œ€์‘

theme.ts

export type MediaQuery = 'mobile' | 'tablet' | 'desktop';

export interface Theme {
 ...
  mediaQuery: {
    [key in MediaQuery]: string;
  };
}

export const light: Theme = {
  ...
  mediaQuery: {
    mobile: '(max-width: 768px)',
    tablet: '(max-width: 1024px)',
    desktop: '(min-width: 1025px)',
  },
};
  • MediaQuery๋ผ๋Š” ์œ ๋‹ˆ์–ธ ํƒ€์ž…์œผ๋กœ ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ํƒ€์ž…์„ ์ง€์ •ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

useMediaQuery.ts

import { getTheme } from '@/style/theme';
import { useEffect, useState } from 'react';

export const useMediaQuery = () => {
  const [isMobile, setIsMobile] = useState(
    window.matchMedia(getTheme('light').mediaQuery.mobile).matches
  );

  useEffect(() => {
    const isMobileQuery = window.matchMedia(
      getTheme('light').mediaQuery.mobile
    );

    setIsMobile(isMobileQuery.matches);
  }, []);

  return { isMobile };
};

๋ฐ˜์‘ํ˜• ์ฒดํฌ์šฉ ์ปค์Šคํ…€ ํ›…์œผ๋กœ, ํ˜„์žฌ ํ™”๋ฉด์ด ๋ชจ๋ฐ”์ผ ์‚ฌ์ด์ฆˆ์ธ์ง€ ํ™•์ธํ•˜์—ฌ boolean ๊ฐ’์œผ๋กœ ๋ฐ˜ํ™˜ํ•ด์ค๋‹ˆ๋‹ค.


์ ์šฉ ์˜ˆ์‹œ

const FooterStyle = styled.footer`
  display: flex;
  justify-content: space-between;

  @media ${({ theme }) => `screen and ${theme.mediaQuery.mobile}`} {
    flex-direction: column;
    align-items: center;
  }
`

๋ฏธ๋””์–ด ์ฟผ๋ฆฌ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์Šคํฌ๋ฆฐ ์‚ฌ์ด์ฆˆ๊ฐ€ (max-width: 768px) ์ฆ‰, ์ตœ๋Œ€ ๊ฐ€๋กœ ๋„“์ด๊ฐ€ 768px์ผ ๋•Œ ์Šคํƒ€์ผ์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.


[ํ•ด์„๋œ ์ฝ”๋“œ์˜ ๋ชจ์Šต]

@media screen and (max-width: 768px) {
  flex-direction: column;
  align-items: center;
}

๊ฒฐ๊ณผ ํ™”๋ฉด




โœ๏ธ ํšŒ๊ณ 

Mock ์„œ๋ฒ„๋ฅผ ํ•œ ๋ฒˆ ๋” ๊ฒฝํ—˜ํ•˜๋ฉด์„œ ์กฐ๊ธˆ ๋” ์ต์ˆ™ํ•ด์ง„ ๊ฒƒ ๊ฐ™๊ณ , mediaQuery ๋˜ํ•œ ์ปค์Šคํ…€ ํ›…์œผ๋กœ ์œ ์šฉํ•˜๊ฒŒ ์“ธ ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ๊ฒŒ ๋˜์—ˆ๋‹ค.

profile
๐ŸŒฑ๊ฐœ๋ฐœ ๊ธฐ๋ก์žฅ

0๊ฐœ์˜ ๋Œ“๊ธ€