본문 바로가기

배워서 따라하는 포플/포켓몬 도감 앱

상세 페이지 생성하기(1)

React Router Dom 적용하기

src폴더 안에 pages폴더 만들어서 router를 관리합니다.

기존 App.jsx파일의 내용은 MainPage폴더의 index.jsx파일에 담겠습니다. import시 경로를 좀 수정해야 합니다.

import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import MainPage from './pages/MainPage'
import DetailPage from './pages/DetailPage'

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<MainPage />} />
        <Route path='/pokemon/:id' element={<DetailPage />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App

App.jsx파일에 router 적용합니다.

 

components폴더 안에 PokeCard.jsx파일의 기존 <a>태그 사용했는데 Link로 수정하겠습니다.

 

모두 완료 후, 스타일이 그대로인지 다시 한번 확인하면 될 듯합니다.

 

데이터 생성 및 가공하기

 

const [pokemon, setPokemon] = useState()
  const [isLoading, setIsLoading] = useState(true)

  const params = useParams()
  const pokemonId = params.id
  const baseUrl = 'https://pokeapi.co/api/v2/pokemon/'

  useEffect(() => {
    fetchPokemonData()
  }, [])

  async function fetchPokemonData() {
    const url = `${baseUrl}${pokemonId}`
    try{
      const {data: pokemonData} = await axios.get(url)

      if(pokemonData) {
        const { name, id, types, weight, height, stats, abilities} = pokemonData
        const nextAndPreviousPokemon = await getNextAndPreviousPokemon(id)

        const DamageRelations = await Promise.all(
          types.map(async (i) => {
            const type = await axios.get(i.type.url)
            return type.data.damage_relations
          })
        )

        const formattedPokemonData = {
          id,
          name,
          weight: weight / 10,
          height: height / 10,
          previous: nextAndPreviousPokemon.previous,
          next: nextAndPreviousPokemon.next,
          abilities: formatPokemonAbilities(abilities),
          stats: formatPokemonStats(stats),
          DamageRelations,
          types: types.map(type => type.type.name)
        }

        setPokemon(formattedPokemonData)
        setIsLoading(false)
      }
    } catch(error) {
      console.error(error)
      setIsLoading(false)
    }
  }

  const formatPokemonStats = ([
    statHP,
    statATK,
    statDEP,
    statSATK,
    statSDEP,
    statSPD
  ]) => [
    {name: 'Hit Points', baseStat: statHP.base_stat},
    {name: 'Attack', baseStat: statATK.base_stat},
    {name: 'Defense', baseStat: statDEP.base_stat},
    {name: 'Special Attack', baseStat: statSATK.base_stat},
    {name: 'Special Defense', baseStat: statSDEP.base_stat},
    {name: 'Speed', baseStat: statSPD.base_stat}
  ]

  const formatPokemonAbilities = (abilities) => {
    return abilities.filter((ability, index) => index <= 1).map((obj) => obj.ability.name.replaceAll('-', ''))
  }

  async function getNextAndPreviousPokemon(id) {
    const urlPokemon = `${baseUrl}?limit=1&offset=${id - 1}`

    const { data: pokemonData } = await axios.get(urlPokemon)

    const nextResponse = pokemonData.next && (await axios.get(pokemonData.next))
    const previousResponse = pokemonData.previous && (await axios.get(pokemonData.previous))

    return {
      next: nextResponse?.data?.results?.[0].name,
      previous: previousResponse?.data?.results?.[0].name
    }
  }

src폴더 안에 pages폴더안에 DetailPage의 index.jsx파일의 함수들입니다.

 

상세페이지 UI 생성하기

if(isLoading) {
    return (
      <div className='absolute h-auto w-auto top-1/3 -translate-x-1/2 left-1/2 z-50'>
        <Loading className='w-12 h-12 z-50 animate-spin text-slate-900' />
      </div>
    )
  }

  if(!isLoading && !pokemon) {
    return (
      <div>...not found</div>
    )
  }

  const img = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon?.id}.png`
  const bg = `bg-${pokemon?.types?.[0]}`
  const text = `text-${pokemon?.types?.[0]}`
  
  return (
    <article className='flex items-center gap-1 flex-col w-full'>
      <div className={`${bg} w-auto h-full flex flex-col z-0 items-center justify-end  relative overflow-hidden`} >
        {pokemon.previous && (
          <Link 
            className='absolute top-[40%] -translate-y-1/2 z-50 left-1'
            to={`/pokemon/${pokemon.previous}`}
          >
            <LessThan className='w-5 h-8 p-1'/>
          </Link>
        )}

        {pokemon.next && (
          <Link 
            className='absolute top-[40%] -translate-y-1/2 z-50 right-1'
            to={`/pokemon/${pokemon.next}`}
          >
            <GreaterThan className='w-5 h-8 p-1'/>
          </Link>
        )}
      </div>
    </article>
  )

src폴더 안에 pages폴더안에 DetailPage의 index.jsx 추가 내용입니다.

export const Loading = ({className: CN = ''}) => {
  <svg
    version="1.1"
    id="loader-1"
    xmlns="http://www.w3.org/2000/svg"
    x="0px"
    y="0px"
    className={CN}
    viewBox="0 0 50 50"
  >
    <path
      fill="currentColor"
      d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z"
    ></path>
  </svg>
}

src폴더 안에 assets폴더안에 Loading.jsx의 내용입니다.

export const LessThan = ({ className: CN = ''}) => (
  <svg
     viewBox="0 0 8 16"
     fill="none"
     xmlns="http://www.w3.org/2000/svg"
     className={CN}
  >
     <g clipPath="url(#clip0_346_14990)">
        <path
           d="M7.228 14.8093L7.84671 14.1906C7.99315 14.0442 7.99315 13.8067 7.84671 13.6603L2.19987 7.99996L7.84671 2.33965C7.99315 2.19321 7.99315 1.95578 7.84671 1.80931L7.228 1.19059C7.08156 1.04415 6.84412 1.04415 6.69765 1.19059L0.153466 7.73478C0.0070281 7.88121 0.0070281 8.11865 0.153466 8.26512L6.69765 14.8093C6.84412 14.9558 7.08156 14.9558 7.228 14.8093Z"
           fill="white"
        />
     </g>
     <defs>
        <clipPath id="clip0_346_14990">
           <rect
              width="8"
              height="16"
              fill="white"
              transform="translate(8 16) rotate(-180)"
           />
        </clipPath>
     </defs>
  </svg>
)

 

src폴더 안에 assets폴더안에 LessThan.jsx의 내용입니다.

export const GreaterThan = ({ className: CN = '' }) => (
  <svg
     viewBox="0 0 8 16"
     fill="none"
     xmlns="http://www.w3.org/2000/svg"
     className={CN}
  >
     <g clipPath="url(#clip0_346_14988)">
        <path
           d="M0.772004 1.19066L0.153285 1.80935C0.00684766 1.95579 0.00684766 2.19322 0.153285 2.33969L5.80013 8.00001L0.153285 13.6603C0.00684766 13.8068 0.00684766 14.0442 0.153285 14.1907L0.772004 14.8094C0.918441 14.9558 1.15588 14.9558 1.30235 14.8094L7.84653 8.26519C7.99297 8.11875 7.99297 7.88132 7.84653 7.73485L1.30235 1.19066C1.15588 1.04419 0.918441 1.04419 0.772004 1.19066Z"
           fill="white"
        />
     </g>
     <defs>
        <clipPath id="clip0_346_14988">
           <rect width="8" height="16" fill="white" />
        </clipPath>
     </defs>
  </svg>
)

src폴더 안에 assets폴더안에 GreaterThan.jsx의 내용입니다.

 

포켓몬 정보 및 타입 UI 생성하기

import React from 'react'

const Type = ({type, damageValue}) => {

  const bg = `bg-${type}`

  return (
    <div>
      <span
        className={`h-[1.5rem] py-1 px-3 rounded-2xl ${bg} font-bold text-zinc-800 text[0.6rem] leading-[0.8rem] capitalize flex gap-1 justify-center items-center`}
      >
        {type}
      </span>
      {damageValue && (
        <span className='bg-zinc-200/40 p-[.125rem] rounded-lg'>
          {damageValue}
        </span>
      )}
    </div>
  )
}

export default Type

src폴더 안에 components폴더안에 Type.jsx의 내용입니다.

<section className='w-full flex flex-col z-20 items-center justify-end relative h-full'>
          <div className='absolute z-30 top-6 flex items-center w-full justify-between px-2'>
            <div className='flex items-center gap-1'>
              <Link to="/">
                <ArrowLeft className='w-6 h-8 text-zinc-200' />
              </Link>
              <h1 className='text-zinc-200 font-bold text-xl capitalize'>
                {pokemon.name}
              </h1>
            </div>
            <div className='text-zinc-200 font-bold text-md'>
              #{pokemon.id.toString().padStart(3, '00')}
            </div>
          </div>

          <div className='relative h-auto max-w-[15.5rem] z-20 mt-6 -mb-16'>
            <img src={img} width='100%' height='auto' loading='lazy' alt={pokemon.name} className='object-contain h-full' />
          </div>
        </section>

        <section className='w-full min-h-[65%] h-full bg-gray-800 z-10 pt-14 flex flex-col items-center gap-3 px-5 pb-4'>
          <div className='flex items-center justify-center gap-4'>
            {pokemon.types.map((type) => (
              <Type key={type} type={type} />
            ))}
          </div>

          <h2 className={`text-base font-semibold ${text}`}>
            정보
          </h2>

          <div className='flex w-full items-center justify-center max-w-[400px] text-center'>
            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Weight</h4>
              <div className='text-sm flex mt-1 gap-2 justify-center text-zinc-200'>
                <Balance />
                {pokemon.weight}kg
              </div>
            </div>

            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Height</h4>
              <div className='text-sm flex mt-1 gap-2 justify-center text-zinc-200'>
                <Vector />
                {pokemon.height}m
              </div>
            </div>

            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Abilities</h4>
              {pokemon.abilities.map((ability) => (
                <div key={ability} className='text-[0.5rem] text-zinc-100 capitalize'>{ability}</div>
              ))}
            </div>
          </div>

          <h2 className={`text-base font-semibold ${text}`}>
            기본 능력치
          </h2>
          <div className='w-full'>
            Stat
          </div>



          {pokemon.DamageRelations && (
            <div className='w-10/12'>
              <h2 className={`text-base text-center font-semibold ${text}`}>
                데미지 관계
              </h2>
              데미지
            </div>
          )}
 </section>

src폴더 안에 pages폴더안에 DetailPage의 index.jsx 추가 내용입니다.

 

BaseStat 컴포넌트 생성하기

import React, {useEffect, useRef} from 'react'

const BaseStat = ({valueStat, nameState, type}) => {
  const bg = `bg-${type}`

  const ref = useRef(null)
  
  useEffect(() => {
    const setValueStat = ref.current
    const calc = valueStat * (100 / 255)
    setValueStat.style.width = calc + '%'
  })

  return (
    <tr className='w-full text-white '>
      <td className='sm:px-5'>{nameState}</td>
      <td className='px-2 sm:px-3'>{valueStat}</td>
      <td>
        <div className='flex items-start h-2 min-w-[10rem] bg-gray-600 rounded overflow-hidden'>
          <div ref={ref} className={`h-3 ${bg}`}></div>
        </div>
      </td>
      <td className='px-2 sm:px-5'>255</td>
    </tr>
  )
}

export default BaseStat

src폴더 안에 components폴더안에 BaseStat.jsx의 내용입니다.

 DetailPage의 index.jsx의 능력치 부분에 들어갑니다.