👉 화면 확인 : 배포 링크
👉 코드 확인 : 깃헙 링크
League of Legends user를 검색하여 최근 게임 이용 시간의 기회 비용을 알아보는 앱
-
기술 학습, 기획, 디자인, 개발, 배포
-
목표: 써보지 않았거나 잘 모르는 중요한 기술들을 학습하고 적응하기 위함.
(우측 기술들 제외하고 전부 : html, css, JS, google-map-react, storejs, react-slick, react-use)
-
세부 주제: 실제 Riot API를 이용해서 user의 최근 15회의 League of Legends 게임 이용 시간을 확인하고 얼만큼의 다른 기회 비용이 있었는지를 알려준다.
Category | Library | Content |
---|---|---|
Base | Next.js | React Framework |
- | Redux Toolkit | State Container |
- | emotion (react, styled) | css styles |
- | TypeScript | Programming Language |
- | Riot Games API | Open API |
Authentication | NextAuth.js | Authentication for Next.js |
- | Prisma (+@prisma/client) | Node.js / TypeScript ORM |
- | PostgreSQL (← SQLite) | object-relational database system |
- | Docker | software platform |
- | bcryptjs | password-hashing |
- | Heroku | Cloud Application Platform |
- | vercel | Deployment Platform |
i18n | next-translate | translations in a Next.js env |
Test | Jest | JS testing Framework |
Map | google-map-react | set of the Google Maps API |
localStorage | storejs | JS API for handling browser localStorage |
Graph | victory | modular charting and data visualization |
Slide | react-slick + slick-carousel | Carousel component |
Etc | bignumber.js | arbitrary-precision arithmetic |
- | date-fns | JS date utility |
- | react-use | Collection of essential React Hooks |
- | axios | Promise based HTTP client |
- | facepaint | Responsive style values for css-in-js |
- | lodash | modern JavaScript utility lib |
자세히 보기
-
방법
-
코드 스플리팅 (by
next/dynamic
) -
useCallback
,useMemo
,memo
등 활용 -
사용에 따라 컴포넌트 분리하여 렌더링 최소화
-
-
확인 React Developer Tools, Profiler / Lighthouse 탭 등을 통해 리렌더링 파악 및 성능 최적화 도모
자세히 보기
함수로 계산된 값들이 정확한지 (기댓값과 일치하는지) 확인
-
./.jest/fn.test.ts
import { convertAllMatch, convertTime } from 'components/member/_shared'; import { addCommas } from 'lib/utils'; describe(`member/_shared/utils`, () => { test('convertAllMatch 함수의 결과 값', () => { const testArr = [ { matchData: { assists: 8, championName: 'Ezreal', deaths: 1, item0: 3042, item1: 3078, item2: 6694, item3: 3158, item4: 3156, item5: 0, item6: 3340, kills: 12, totalDamageDealtToChampions: 24287, totalMinionsKilled: 159, win: true, }, time: { gameCreation: 1657200710000, gameDuration: 1712 }, }, { matchData: { assists: 8, championName: 'Ezreal', deaths: 2, item0: 3042, item1: 3078, item2: 3133, item3: 1036, item4: 1036, item5: 1001, item6: 3340, kills: 8, totalDamageDealtToChampions: 17167, totalMinionsKilled: 149, win: true, }, time: { gameCreation: 1657197354000, gameDuration: 1634 }, }, { matchData: { assists: 7, championName: 'Akali', deaths: 5, item0: 1054, item1: 4633, item2: 3020, item3: 3165, item4: 4645, item5: 4630, item6: 3340, kills: 7, totalDamageDealtToChampions: 16870, totalMinionsKilled: 188, win: false, }, time: { gameCreation: 1657031470000, gameDuration: 2033 }, }, ]; const expectResult = { gameMillisecTime: 5379000, playinDate: [ { gameCreation: 1657200710000, gameDuration: 3346 }, { gameCreation: 1657031470000, gameDuration: 2033 }, ], }; expect(convertAllMatch(testArr)).toStrictEqual(expectResult); }); test('convertTime 함수의 결과 값', () => { expect(convertTime(26914000)).toStrictEqual({ timeBlock: { day: '0', hours: '7', minutes: '448' }, opportunityCost: { PCroom: ['9,100', 'moneyUnit'], love: ['0', 'movieUnit'], sleep: ['7', 'hours'], study: ['448', 'studyUnit'], wage: ['64,120', 'moneyUnit'], walk: ['2,450', 'walkUnit'], }, }); }); }); describe('lib/utils', () => { test('addCommas 함수의 결과 값', () => { expect(addCommas(27000)).toStrictEqual('27,000'); }); });
자세히 보기
bignumber.js
를 통해 정밀한 산수 계산
-
./components/member/_shared/utils.ts
const toDays = addCommas(new BigNumber(toHoursNum).div(24).toNumber()); const toWage = addCommas(new BigNumber(toHoursNum).multipliedBy(wageThisYear).toNumber()); const toSleep = addCommas(toHoursNum); const paidPCroom = addCommas(new BigNumber(toHoursNum).multipliedBy(1300).toNumber()); const toWalkCalories = addCommas(new BigNumber(toHoursNum).multipliedBy(350).toNumber()); const study1WordPer1Min = addCommas(new BigNumber(toMinutes).div(1).toNumber());
자세히 보기
회원가입, 로그인, 로그인 유지, 로그아웃, Validation check 구현
-
메인 라이브러리: NextAuth
-
DB구축: PostgreSQL + Docker + Prisma + Heroku
-
비밀번호 암호화 bcryptjs
-
로그인 유지
-
로그아웃 할 때까지 로그인 유지
-
자신의 게임 기록으로 바로 이동 가능
-
-
./pages/api/auth/signup.ts
import { NextApiRequest, NextApiResponse } from 'next'; import { PrismaClient } from 'prisma/prisma-client'; import { hashSync } from 'bcryptjs'; async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') return; let prisma = new PrismaClient(); const data = req.body; const isExistedUser = await prisma.user.findUnique({ where: { email: data.email, }, select: { email: true, name: true, }, }); if (isExistedUser) { res.status(422).json({ message: 'User Email already exists!', error: true }); return; } const result = await prisma.user.create({ data: { ...data, password: hashSync(data.password, 12) }, }); if (result) { res.status(201).json({ message: 'Created user!', error: false }); } else { res.status(402).json({ message: 'Prisma error occured', error: true }); } } export default handler;
자세히 보기
screen의 width길이나 세로와 가로비율에 따라 적절한 UI를 보여줌
-
구현 방법
-
HTML
picture
/source
의media
,srcSet
속성 /img
tags -
CSS(
flex
,media query
) -
facepaint
라이브러리 -
코드 (
./styles/mixin/index.ts
)const breakpoints = [376, 600, 768, 1000, 1200, 1300, 1800, 2400]; const screenMode = ['landscape', 'portrait']; export const responsive = { onlyScreen: facepaint(breakpoints.map((bp) => `@media only screen and (min-width: ${bp}px)`)), isPortraitOrLandscape: facepaint( screenMode.map((screenMode) => `@media only screen and (orientation: ${screenMode})`) ), };
-
-
UI 확인
자세히 보기
promise.all
활용
-
다량의(15개) API 호출을 동시에 해서 대기 시간을 감소 시킨 뒤, 필요한 정보만 추출하고 정리하여 한 번에 client state에 내려줌.
(주어진 API 데이터에서 원하는 정보를 얻기 위한 최선의 방법)
-
./pages/api/riot/[summonerName].ts
export default async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'GET') { const { summonerName, region } = req.query; const selectedRegionAPI = setRoutingRegion[`${region}`]; const selectedContinentAPI = setRoutingContinent[`${region}`]; if (!summonerName) { res.statusCode = 400; return res.send('소환사명이 없습니다.'); } try { const { data: { puuid, profileIconId }, } = await axios.get(encodeURI(findBasicInfoOfSummonerAPI(`${summonerName}`, `${selectedRegionAPI}`))); const { data: matchIdLists } = await axios.get(findMatchListsAPI(`${puuid}`, `${selectedContinentAPI}`)); const allMatchData = await Promise.all( matchIdLists.map(async (matchId: string[]) => { const eachMatchResult = await axios.get(findAllMatchDataAPI(`${matchId}`, `${selectedContinentAPI}`)); const { gameCreation, gameDuration, participants } = eachMatchResult.data.info; const { win, championName, totalDamageDealtToChampions, totalMinionsKilled, deaths, kills, assists, item0, item1, item2, item3, item4, item5, item6, item7, } = participants.filter((participant: IParticipant) => participant.summonerName === summonerName)[0]; return { time: { gameCreation, gameDuration }, matchData: { win, championName, totalDamageDealtToChampions, totalMinionsKilled, deaths, kills, assists, item0, item1, item2, item3, item4, item5, item6, item7, }, }; }) ); const result = { profileIconId, allMatchData, }; res.statusCode = 200; return res.send(result); } catch (e) { res.statusCode = 404; if (axios.isAxiosError(e) && e.response) { console.log(e.response); } return res.end(); } } res.statusCode = 405; return res.end(); };
자세히 보기
radio 버튼이나 지도에서 국가 선택 가능
-
./components/home/regionModal/index.tsx
interface IProps { closeModal: () => void; } const RegionModal = ({ closeModal }: IProps) => { const { abbreviation: selectedAbbreviation, lat: selectedLat, lng: selectedLng, } = useSelector((state) => state.common.region); const dispatch = useDispatch(); const onClickCloseBtn = () => closeModal(); const onClickOption = ({ abbreviation, lat, lng }: IRegion) => dispatch(commonActions.setRegion({ abbreviation, lat, lng })); return ( <section css={S.container}> <button css={S.closeIcon} onClick={onClickCloseBtn}> <CloseIcon /> </button> <h5 css={S.title}>지역 선택</h5> <section css={S.mapContainer}> <GoogleMapReact bootstrapURLKeys={{ key: process.env.MY_GOOGLE_MAP_API! }} defaultCenter={{ lat: REGION_OPTIONS[0].lat, lng: REGION_OPTIONS[0].lng }} defaultZoom={0} center={{ lat: selectedLat, lng: selectedLng }} > {REGION_OPTIONS.map((region) => { const { abbreviation, continent, lat, lng } = region; return ( <S.Marker key={abbreviation} lat={lat} lng={lng} isSelected={abbreviation === selectedAbbreviation} onClick={() => onClickOption(region)} > {continent} </S.Marker> ); })} </GoogleMapReact> </section> <ul css={S.optionContainer}> {REGION_OPTIONS.map((region) => { const { abbreviation, continent } = region; return ( <li key={abbreviation}> <S.ContinentBtn onClick={() => onClickOption(region)} isSelected={abbreviation === selectedAbbreviation} > <div /> {continent} </S.ContinentBtn> </li> ); })} </ul> <button css={S.saveBtn} type='button' onClick={onClickCloseBtn}> 저장하기 </button> </section> ); }; export default RegionModal;
자세히 보기
-
하루 동안 검색한 내역을 local storage에 저장하여 보여주고 자정이 지난 뒤에 사이트를 이용하면 전 날의 local storage에 있던 내역은 지워진다.
-
Responsive
화면 줄어들면 사라짐 (코어 기능은 아니기 때문에)
-
검색한 것을 다시 검색할 경우, 리스트 맨 위로 올라감
-
최근 본 유저 리스트 옆에 stickey로 고정
-
클릭 시, 검색됨.
-
./components/member/qnb/index.tsx
useEffect(() => { const newSearchedList = [ { searchedName, region, profileImg, expiredAt: getTime(endOfDay(new Date())) }, ...(store.get('recent searches') ?? ''), ]; const result = uniqBy(filterExpired(newSearchedList), 'searchedName'); store.set('recent searches', result); setRecentSearches(result); }, []);
자세히 보기
날짜 별로 게임 시간을 산출하여 그래프로 시각화.
-
./components/member/graph/index.tsx
<div css={S.container}> <VictoryChart {...GRAPH_OPTIONS.chart}> <VictoryAxis tickValues={convertedLeftAxisTickValues} tickLabelComponent={<VictoryLabel {...GRAPH_OPTIONS.leftAxisLabel} />} /> <VictoryBar data={convertedData} style={{ data: { fill: ({ datum }) => datum.fill } }} {...GRAPH_OPTIONS.bar} /> <VictoryAxis tickFormat={convertedRightAxisTickValues} tickLabelComponent={<VictoryLabel {...GRAPH_OPTIONS.rightAxisLabel} />} {...GRAPH_OPTIONS.rightAxis} /> </VictoryChart> </div>
자세히 보기
-
Copy URL, Kakao talk, facebook, twitter에 사이트 공유 가능
-
Meta Tag , OG 활용
-
코드 (
./components/layout/index.tsx
)<Head> <title>{titleSet}</title> <meta property='og:type' content='website' /> <meta property='og:title' content={ogTitleSet} /> <meta property='og:site_name' content={`${t('common:titleOfApp')}`} /> <meta property='og:description' content={`${t('common:descOfApp')}`} /> <meta property='og:image' content={OG_IMAGE_URL} /> <meta property='og:image:width' content='1200' /> <meta property='og:image:height' content='630' /> <meta property='og:image:alt' content={`${t('common:titleOfApp')} image`} /> <meta property='og:url' content='https://while-you-were-playing-lol.vercel.app' /> </Head>
자세히 보기
window.scrollY
, transition
을 활용하여 적절한 위치에서 텍스트를 노출시키고, nav bar를 줄이고 늘리는 애니메이션 효과를 줌.
-
./components/layout/index.tsx
const onScroll = () => dispatch(commonActions.setScrollPosition(window.scrollY)); useEffect(() => { window.addEventListener('scroll', onScroll); return () => { window.removeEventListener('scroll', onScroll); }; }, []);
-
./components/home/searchSection/index.tsx
<S.DivisionText isOpacityOn={scrollPosition >= 130}>(...)</S.DivisionText> <S.DivisionFigure isOpacityOn={scrollPosition >= 200}> (...) </S.DivisionFigure> <S.SectionTitle isOpacityOn={scrollPosition >= 242}>(...)</S.SectionTitle> <S.Title isOpacityOn={scrollPosition >= 365}> (...) </S.Title> <S.Desc isOpacityOn={scrollPosition >= 475}> (...) </S.Desc> <S.Form onSubmit={onSubmit} isOpacityOn={scrollPosition >= 600}> (...) </S.Form>