typescript 설치
npm install -D typescript @types/react @types/react-dom
위 명령어대로 ts를 설치합니다.
vite.config.js를 .ts로 변경합니다.
루트 경로에 tsconfig.json, tsconfig.node.json 파일을 생성하며, src폴더 안에 vite-env.d.ts파일을 생성합니다.
이거는 npm init vite를 하고 typescript를 선택하면 자동적으로 생성한 시스템파일입니다.
새로운 ts프로젝트를 생성 후, 가져와서 붙여넣기해도 좋습니다.
type전환 방법
간단한 변수는 직접 작성해도 됩니다.
좀 길고 복잡한 데이터 같은 경우 console.log에서 JSON.stringify(type.data)를 해서 console에 확인합니다.
출력된 내용을 복사해서 app.quicktype.io 에서 형 변환하면 됩니다.
index.html 타입 변경
src폴더 안에 main.jsx를 .tsx로 변경합니다.
as HTMLElement를 추가하여 에러를 없앱니다.
경로에 에러 뜨는데 tsconfig.json의 allowJs를 true로 변경하면 경로 에러 안 뜹니다.
루트경로의 index.html에서 main.tsx로 변경합니다.
메인 페이지 타입 변경
MainPage폴더안에 index.jsx를 .tsx로 변경합니다. 타입 에러들을 많이 생길겁니다.
export interface PokemonData {
count: number;
next: string | null;
previous: string |null ;
results: PokemonNameAndUrl[]
}
export interface PokemonNameAndUrl {
name: string;
url: string;
}
src폴더 안에 types폴더 생성 후, PokemonData.ts파일의 코드입니다.
앞으로 type은 모두 types폴더 안에서 관리하겠습니다.
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import PokeCard from '../../components/PokeCard'
import AutoComplete from '../../components/AutoComplete'
import { PokemonData, PokemonNameAndUrl } from '../../types/PokemonData'
function MainPage() {
// 모든 포켓몬 데이터를 가지고 있는 state
const [allPokemons, setAllPokemons] = useState<PokemonNameAndUrl[]>([])
// 실제로 리스트로 보여주는 포켓몬 데이터를 가지고 있는 state
const [displayedPokemons, setDisplayedPokemons] = useState<PokemonNameAndUrl[]>([])
// 한번에 보여주는 포켓몬 수
const limitNum = 20
const url = 'https://pokeapi.co/api/v2/pokemon/?limit=1008&offset=0'
useEffect(() => {
fetchPokeData()
}, [])
const filterDisplayedPokemonData = (
allPokemonsData: PokemonNameAndUrl[],
displayedPokemons: PokemonNameAndUrl[] = []
) => {
const limit = displayedPokemons.length + limitNum
// 모든 포멧몬 데이터에서 limitNum만큼 더 가져오기
const array = allPokemonsData.filter((pokemon, index) => index + 1 <= limit)
return array
}
const fetchPokeData = async () => {
try {
// 1008 포켓몬 데이터 받아오기
const response = await axios.get<PokemonData>(url)
// 모든 포켓몬 데이터 기억하기
setAllPokemons(response.data.results)
// 실제로 화면에 보여줄 포켓몬 리스트 기억하기
setDisplayedPokemons(filterDisplayedPokemonData(response.data.results))
} catch(error) {
console.error(error)
}
}
return (
<article className='pt-6'>
<header className='flex flex-col gap-2 w-full px-4 z-50'>
<AutoComplete
allPokemons={allPokemons}
setDisplayedPokemons={setDisplayedPokemons}
/>
</header>
<section className='pt-6 flex flex-col justify-content items-center overflow-auto z-0'>
<div className='flex flex-row flex-wrap gap-[16px] items-center justify-center px-2 max-w-4xl'>
{displayedPokemons.length > 0 ?
(
displayedPokemons.map(({url, name}: PokemonNameAndUrl) => (
<PokeCard key={url} url={url} name={name}/>
))
) :
(
<h2 className='font-medium text-lg text-slate-900 mb-1'>
포켓몬이 없습니다.
</h2>
)
}
</div>
</section>
<div className='text-center'>
{(allPokemons.length > displayedPokemons.length) && (displayedPokemons.length !== 1) &&
(
<button
onClick={() => setDisplayedPokemons(filterDisplayedPokemonData(allPokemons, displayedPokemons))}
className='bg-slate-800 px-6 py-2 my-4 text-base rounded-lg font-bold text-white'>
더 보기
</button>
)
}
</div>
</article>
)
}
export default MainPage
MainPage폴더안에 index.tsx파일에 필요한 부분에만 타입을 입력했습니다.
AutoComplete 컴포넌트 타입 변경
interface AutoCompleteProps {
allPokemons: PokemonNameAndUrl[],
setDisplayedPokemons: React.Dispatch<React.SetStateAction<PokemonNameAndUrl[]>>
}
AutoComplete.tsx파일 내부에서 type을 선언합니다.
에러난 부분의 타입을 명시해주면 됩니다.
PokeCard 컴포넌트 타입 변경
export interface PokemonDetail {
abilities: Ability[];
base_experience: number;
forms: Species[];
game_indices: GameIndex[];
height: number;
held_items: any[];
id: number;
is_default: boolean;
location_area_encounters: string;
moves: Move[];
name: string;
order: number;
past_types: any[];
species: Species;
sprites: Sprites;
stats: Stat[];
types: Type[];
weight: number;
}
export interface Ability {
ability: Species;
is_hidden: boolean;
slot: number;
}
export interface Species {
name: string;
url: string;
}
export interface GameIndex {
game_index: number;
version: Species;
}
export interface Move {
move: Species;
version_group_details: VersionGroupDetail[];
}
export interface VersionGroupDetail {
level_learned_at: number;
move_learn_method: Species;
version_group: Species;
}
export interface GenerationV {
"black-white": Sprites;
}
export interface GenerationIv {
"diamond-pearl": Sprites;
"heartgold-soulsilver": Sprites;
platinum: Sprites;
}
export interface Versions {
"generation-i": GenerationI;
"generation-ii": GenerationIi;
"generation-iii": GenerationIii;
"generation-iv": GenerationIv;
"generation-v": GenerationV;
"generation-vi": { [key: string]: Home };
"generation-vii": GenerationVii;
"generation-viii": GenerationViii;
}
export interface Sprites {
back_default: string;
back_female: null;
back_shiny: string;
back_shiny_female: null;
front_default: string;
front_female: null;
front_shiny: string;
front_shiny_female: null;
other?: Other;
versions?: Versions;
animated?: Sprites;
}
export interface GenerationI {
"red-blue": RedBlue;
yellow: RedBlue;
}
export interface RedBlue {
back_default: string;
back_gray: string;
back_transparent: string;
front_default: string;
front_gray: string;
front_transparent: string;
}
export interface GenerationIi {
crystal: Crystal;
gold: Gold;
silver: Gold;
}
export interface Crystal {
back_default: string;
back_shiny: string;
back_shiny_transparent: string;
back_transparent: string;
front_default: string;
front_shiny: string;
front_shiny_transparent: string;
front_transparent: string;
}
export interface Gold {
back_default: string;
back_shiny: string;
front_default: string;
front_shiny: string;
front_transparent?: string;
}
export interface GenerationIii {
emerald: OfficialArtwork;
"firered-leafgreen": Gold;
"ruby-sapphire": Gold;
}
export interface OfficialArtwork {
front_default: string;
front_shiny: string;
}
export interface Home {
front_default: string;
front_female: null;
front_shiny: string;
front_shiny_female: null;
}
export interface GenerationVii {
icons: DreamWorld;
"ultra-sun-ultra-moon": Home;
}
export interface DreamWorld {
front_default: string;
front_female: null;
}
export interface GenerationViii {
icons: DreamWorld;
}
export interface Other {
dream_world: DreamWorld;
home: Home;
"official-artwork": OfficialArtwork;
}
export interface Stat {
base_stat: number;
effort: number;
stat: Species;
}
export interface Type {
slot: number;
type: Species;
}
types폴더 안에 PokemonDetail.ts 파일을 위와 같이 작성합니다.
interface PokeData {
id: number;
type: string;
name: string;
}
PokeCard.tsx파일 내부에서 type을 선언합니다.
에러난 부분의 타입을 명시해주면 됩니다.
PokeData와 PokemonDetail는 import해야 됩니다.
LazyImage 컴포넌트 타입 변경
interface LazyImageProps {
url: string;
alt: string;
}
LazyImage.tsx파일 내부에서 type을 선언합니다.
에러난 부분의 타입을 명시해주면 됩니다.
상세페이지 타입 변경
export interface FormattedPokemonData {
id: number;
name: string;
weight: number;
height: number;
previous: string | undefined;
next: string | undefined;
abilities: string[];
stats: Stat[];
DamageRelations: DamageRelation[];
types: string[];
sprites: string[];
description: string;
}
export interface DamageRelation {
double_damage_from: DoubleDamageFrom[];
double_damage_to: DoubleDamageFrom[];
half_damage_from: DoubleDamageFrom[];
half_damage_to: DoubleDamageFrom[];
no_damage_from: any[];
no_damage_to: any[];
}
export interface DoubleDamageFrom {
name: string;
url: string;
}
export interface Stat {
name: string;
baseStat: number;
}
types폴더 안에 FormattedPokemonData.ts 파일의 코드를 위와 같이 작성합니다.
export interface PokemonDescription {
base_happiness: number;
capture_rate: number;
color: Color;
egg_groups: Color[];
evolution_chain: EvolutionChain;
evolves_from_species: Color;
flavor_text_entries: FlavorTextEntry[];
form_descriptions: any[];
forms_switchable: boolean;
gender_rate: number;
genera: Genus[];
generation: Color;
growth_rate: Color;
habitat: Color;
has_gender_differences: boolean;
hatch_counter: number;
id: number;
is_baby: boolean;
is_legendary: boolean;
is_mythical: boolean;
name: string;
names: Name[];
order: number;
pal_park_encounters: PalParkEncounter[];
pokedex_numbers: PokedexNumber[];
shape: Color;
varieties: Variety[];
}
export interface Color {
name: string;
url: string;
}
export interface EvolutionChain {
url: string;
}
export interface FlavorTextEntry {
flavor_text: string;
language: Color;
version: Color;
}
export interface Genus {
genus: string;
language: Color;
}
export interface Name {
language: Color;
name: string;
}
export interface PalParkEncounter {
area: Color;
base_score: number;
rate: number;
}
export interface PokedexNumber {
entry_number: number;
pokedex: Color;
}
export interface Variety {
is_default: boolean;
pokemon: Color;
}
types폴더 안에 PokemonDescription.ts 파일의 코드를 위와 같이 작성합니다.
export interface DamageRelationOfPokemonType {
damage_relations: DamageRelations;
game_indices: GameIndex[];
generation: Generation;
id: number;
move_damage_class: Generation;
moves: Generation[];
name: string;
names: Name[];
past_damage_relations: PastDamageRelation[];
pokemon: Pokemon[];
}
export interface DamageRelations {
double_damage_from: Generation[];
double_damage_to: Generation[];
half_damage_from: Generation[];
half_damage_to: Generation[];
no_damage_from: Generation[];
no_damage_to: Generation[];
}
export interface Generation {
name: string;
url: string;
}
export interface GameIndex {
game_index: number;
generation: Generation;
}
export interface Name {
language: Generation;
name: string;
}
export interface PastDamageRelation {
damage_relations: DamageRelations;
generation: Generation;
}
export interface Pokemon {
pokemon: Generation;
slot: number;
}
types폴더 안에 DamageRelationOfPokemonTypes.ts 파일의 코드를 위와 같이 작성합니다.
import axios from 'axios';
import React, { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Loading } from '../../assets/Loading';
import { LessThan } from '../../assets/LessThan';
import { GreaterThan } from '../../assets/GreaterThan';
import { ArrowLeft } from '../../assets/ArrowLeft';
import { Balance } from '../../assets/Balance';
import { Vector } from '../../assets/Vector';
import Type from '../../components/Type';
import BaseStat from '../../components/BaseStat';
import DamageRelations from '../../components/DamageRelations';
import DamageModal from '../../components/DamageModal';
import { FormattedPokemonData } from '../../types/FormattedPokemonData';
import { Ability, PokemonDetail, Sprites, Stat } from '../../types/PokemonDetail';
import { DamageRelationOfPokemonType } from '../../types/DamageRelationOfPokemonTypes';
import { FlavorTextEntry, PokemonDescription } from '../../types/PokemonDescription';
import { PokemonData } from '../../types/PokemonData';
interface NextAndPreviousPokemon {
next: string | undefined;
previous: string | undefined;
}
const DetailPage = () => {
const [pokemon, setPokemon] = useState<FormattedPokemonData>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isModalOpen, setIsModalOpen] = useState<boolean>(false)
const params = useParams() as {id: string}
const pokemonId = params.id
const baseUrl = 'https://pokeapi.co/api/v2/pokemon/'
useEffect(() => {
setIsLoading(true)
fetchPokemonData(pokemonId)
}, [pokemonId])
async function fetchPokemonData(pokemonId: string) {
const url = `${baseUrl}${pokemonId}`
try{
const {data: pokemonData} = await axios.get<PokemonDetail>(url)
if(pokemonData) {
const { name, id, types, weight, height, stats, abilities, sprites} = pokemonData
const nextAndPreviousPokemon: NextAndPreviousPokemon = await getNextAndPreviousPokemon(id)
const DamageRelations = await Promise.all(
types.map(async (i) => {
const type = await axios.get<DamageRelationOfPokemonType>(i.type.url)
return type.data.damage_relations
})
)
const formattedPokemonData: 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),
sprites: formatPokemonSprites(sprites),
description: await getPokemonDescription(id)
}
setPokemon(formattedPokemonData)
setIsLoading(false)
}
} catch(error) {
console.error(error)
setIsLoading(false)
}
}
const filterAndFormatDescription = (flavorText: FlavorTextEntry[]): string[] => {
const koreanDescriptions = flavorText
?.filter((text) => text.language.name === "ko")
.map((text) => text.flavor_text.replace(/\r|\n|\f/g, ' '))
return koreanDescriptions
}
const getPokemonDescription = async (id: number): Promise<string> => {
const url =`https://pokeapi.co/api/v2/pokemon-species/${id}/`
const {data: pokemonSpecies} = await axios.get<PokemonDescription>(url)
const descriptions = filterAndFormatDescription(pokemonSpecies.flavor_text_entries)
return descriptions[Math.floor(Math.random() * descriptions.length)]
}
const formatPokemonSprites = (sprites: Sprites) => {
const newSprites = {...sprites};
(Object.keys(newSprites) as (keyof typeof newSprites)[]).forEach(key => {
if(typeof newSprites[key] !== 'string') {
delete newSprites[key]
}
})
return Object.values(newSprites) as string[]
}
const formatPokemonStats = ([
statHP,
statATK,
statDEP,
statSATK,
statSDEP,
statSPD
]: Stat[]) => [
{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: Ability[]) => {
return abilities.filter((ability, index) => index <= 1).map((obj) => obj.ability.name.replaceAll('-', ''))
}
async function getNextAndPreviousPokemon(id: number) {
const urlPokemon = `${baseUrl}?limit=1&offset=${id - 1}`
const { data: pokemonData } = await axios.get(urlPokemon)
const nextResponse = pokemonData.next && (await axios.get<PokemonData>(pokemonData.next))
const previousResponse = pokemonData.previous && (await axios.get<PokemonData>(pokemonData.previous))
return {
next: nextResponse?.data?.results?.[0].name,
previous: previousResponse?.data?.results?.[0].name
}
}
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>
)}
<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
onClick={() => setIsModalOpen(true)}
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'>
<table>
<tbody>
{pokemon.stats.map((stat) => (
<BaseStat
key={stat.name}
valueStat={stat.baseStat}
nameState={stat.name}
type={pokemon.types[0]}
/>
))}
</tbody>
</table>
</div>
<h2 className={`text-base font-semibold ${text}`}>
설명
</h2>
<p className='text-md leading-4 font-sans text-zinc-200 max-w-[30rem] text-center'>
{pokemon.description}
</p>
<div className="flex my-8 flex-wrap justify-center">
{pokemon.sprites.map((url,index) => (
<img key={index} src={url} alt='sprite' />
))}
</div>
</section>
</div>
{isModalOpen && <DamageModal setIsModalOpen={setIsModalOpen} damages={pokemon.DamageRelations} />}
</article>
)
}
export default DetailPage
수정된 DetailPage의 index.tsx입니다. (일부 미완성)
Assets 컴포넌트들 타입 변경
export interface ClassNameProps {
className: string;
}
types폴더 안에 ClassNameProps.ts 파일의 코드를 위와 같이 작성합니다.
Assets폴더 안에 컴포넌트들을 다 타입을 명시합니다.
'배워서 따라하는 포플 > 포켓몬 도감 앱' 카테고리의 다른 글
타입스크립트로 변경하기(2) (0) | 2023.09.18 |
---|---|
로그인 페이지 생성하기 (0) | 2023.09.17 |
상세 페이지 생성하기(3) (0) | 2023.09.17 |
상세 페이지 생성하기(2) - Damage Relations (0) | 2023.09.16 |
상세 페이지 생성하기(1) (0) | 2023.09.14 |