프로그래머스 프론트엔드 데브매칭 고양이 사이트 정리

창현·2021년 2월 24일
8
post-thumbnail

소스코드 참고 시 출처 표기 부탁드립니다!

깃허브 repository

요구사항

참고 요구사항의 순서는 난이도와 상관이 없음

HTML, CSS 관련

  • 현재 HTML 코드가 전체적으로 <div> 로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.
  • 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 적절히 변경해주어야 합니다.
    • 992px 이하: 3개
    • 768px 이하: 2개
    • 576px 이하: 1개
/* 
device-width is deprecated
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/device-width 
*/

@media only screen and (max-width: 992px) {
  .SearchResult {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media only screen and (max-width: 768px) {
  .SearchResult {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media only screen and (max-width: 576px) {
  .SearchResult {
    display: block;
  }
}
  • 다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다.
    • CSS 파일 내의 다크 모드 관련 주석을 제거한 뒤 구현합니다.
    • 모든 글자 색상은 #FFFFFF , 배경 색상은 #000000 로 한정합니다.
    • 기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
/* css OS dark mode */

@media (prefers-color-scheme: dark) {
  body {
    --bg: #000;
    --font: #fff;

    background-color: var(--bg);
    color: var(--font);
  }
}
/*
 js OS dark mode 
 
 버튼 토글 전 => css에서 prefers-color-scheme로 스타일링
 버튼 토글 시 => body의 data-theme 속성 토글링 => css에서 body[data-theme='dark']{} 셀렉터로 스타일링
*/

 // DarkModeToggler.js
 
 $darkModeToggler.onclick = () => {
   let originTheme = document.body.dataset.theme;

   if (!originTheme) {
     originTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
       ? 'dark'
       : 'light';
   }

   let toggledTheme = originTheme === 'dark' ? 'light' : 'dark';

   document.body.setAttribute('data-theme', toggledTheme);
 };

이미지 상세 보기 모달 관련

  • 디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.
  • 필수 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.
// Imageinfo.js (모달 UI로 변경 예정)

// tabindex 속성 
$el.setAttribute('tabindex', 0);

onClick(e) {
  const clickedClassName = e.target.className;
  if (
    clickedClassName === 'close' || // 닫기 버튼
    clickedClassName.indexOf('ImageInfo') !== -1 // 백드랍 클릭
  ) {
    this.removeWithFadeOut();
  }
}
onKeyDown(e) {
  e.key === 'Escape' && this.removeWithFadeOut(); // ESC 키보드 입력 시
}

// 렌더링 시 focus하기
render(){
  /.../
  this.$el.focus();
}

this.$el.addEventListener('click', this.onClick);
this.$el.addEventListener('keydown', this.onKeyDown);
  • 모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id 를 통해 불러와야 합니다.
// SearchResult.js

SearchResult 클릭  => target이 고양이카드일 경우 => dataset-id를 이용해 api fetch => fetched data는 dataset을 이용해 캐싱 => data를 이용해 Imageinfo 렌더링 

constructor(){
  this.onClick = this.onClick.bind(this);
}

async getCatInfo($catCard) {
  if ($catCard.dataset.catInfo) { // 캐싱된 데이터가 있을 경우
    return JSON.parse($catCard.dataset.catInfo);
  }

  try {
    const { data } = await api.fetchCatById($catCard.dataset.id);

    if (!Object.keys(data).length) { // api 요청 성공, but 데이터가 없을 때
      throw new Error(errorMessage);
    }

    $catCard.dataset.catInfo = JSON.stringify(data); // 캐싱

    const { id, url, name, temperament, origin } = data;

    return { id, url, name, temperament, origin };
  } catch (e) {
    console.error(e);

    throw new Error(
      e.message === errorMessage // api 요청은 성공했으나 data가 없을 경우
        ? e.message
        : '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.' //api 요청에 실패할 경우
    );
  }
}
  
async onClick(e) {
    if (this.isLoading) return; // 로딩 중일 경우 클릭 이벤트 방지 

    const $catCard = e.target.closest('.item');
    if (!$catCard) {
      return;
    }
    
   const loading = new Loading();
   this.isLoading = true;
    try {
      const catInfo = await this.getCatInfo($catCard);

      new ImageInfo(document.body, catInfo).render();
    } catch (e) {
      new ErrorMessage($catCard, e.message);
    } finally {
      loading.$el.remove();
      this.isLoading = false;
    }
}
  
this.$el.addEventListener('click', this.onClick);
  • 추가 모달 열고 닫기에 fade in/out을 적용해 주세요.
// 렌더링 시
this.$el.add('fade-in');

// 언마운팅 시
this.$el.remove('fade-in');
this.$el.add('fade-out');

검색 페이지 관련

  • 페이지 진입 시 포커스가 input 에 가도록 처리하고, 키워드를 입력한 상태에서 input 을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.
// App.js
앱 렌더링 후, dom traverse해서 input을 찾은 후 focus하기
render() {
  this.children.forEach((child) => child.render && child.render());
  this.$target.querySelector('.SearchInput').focus();
}

// SearchInput.js

onClick = (e) => {
  this.$el.value = '';
};
  • 필수 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.
// Loading.js

export default class Loading {
  constructor() {
    this.$el = document.createElement('div');
    this.$el.className = 'loading';
    this.$el.innerHTML = `
      <span class="loading">
        <span class="loading-dot"></span>
        <span class="loading-dot"></span>
        <span class="loading-dot"></span>
      </span>
    `;

    document.body.append(this.$el);
  }
}

// usage

// 렌더링
const loading = new Loading()

// 언마운팅
loading.$el.remove()
// https://codepen.io/changhyun2/pen/GRjEYzO

.loading {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.6);
  display: flex;
  justify-content: center;
  align-items: center;
}

.loading-dot {
  display: inline-block;
  background: linear-gradient(-90deg, #666, #999, #bbb, #fff);
  background-size: 600% 600%;
  animation: gradient 1s infinite, flick 1s infinite;
  line-height: 1;
  vertical-align: middle;
  width: 15px;
  height: 15px;
  border-radius: 100%;
  background-color: #000;
}
.loading-dot:nth-child(2) {
  animation-delay: 0.2s;
}
.loading-dot:last-child {
  animation-delay: 0.4s;
}

.loading-dot:not(:last-child) {
  margin-right: 3px;
}

@keyframes gradient {
  0% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  10% {
    background-position: 0% 50%;
  }
}

@keyframes flick {
  0%,
  80%,
  100% {
    opacity: 0;
  }
  30%,
  50% {
    opacity: 1;
  }
}
  • 필수 검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.
불러올 수 있는 cat keyword 리스트를 렌더링하면 좋을 듯
일단 에러메세지로 대체

// SearchInput.js
  try {
    const res = await api.fetchCats(searchedKeyword);
  } catch (e) {
    new ErrorMessage({
      $target: this.$el.closest('.Search'),
      message:
        e.message === errorMessage
          ? e.message
          : '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.',
    });
  }
  
// ErrorMessage.js

const DURATION = 1500;

export default class ErorrMessage {
  constructor($target = document.body, message) {
    const { bottom, left } = $target.getBoundingClientRect();

    const $el = document.createElement('div');
    $el.className = 'error-message';
    $el.textContent = message;
    $el.style.position = 'fixed';
    $el.style.left = left + 'px';
    $el.style.top = `${bottom + 50}px`;
    $el.style.zIndex = 1001;
    this.$el = $el;

    $target.insertAdjacentElement('afterend', this.$el);
    this.$el.classList.add('fade-in');

    setTimeout(() => {
      this.$el.classList.remove('fade-in');
      this.$el.classList.add('fade-out');
      this.$el.ontransitionend = () => this.$el.remove();
    }, DURATION);
  }
}
  • 최근 검색한 키워드를 SearchInput 아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다.
/* 
  store.js 임시 구현
  
  context를 create하는 컴포넌트 => constructor에서 store.set('context', initialData)하기
  context의 update에 따라 리렌더링되는 컴포넌트 => constructor에서 store.subscribe('context', this)하기
  
  context를 set하는 코드 라인 => data를 업데이트하는 곳에서 store.set('context', data)하기
  context를 get하는 코드 라인 => data를 사용하는 곳에서 store.get('context')하기
*/

const store = {
  subscribe: (context, ref) => {
    store[context].refs.push(ref);
  },
  get: (context) => {
    return store[context].data;
  },
  set: (context, data) => {
    const initialSet = !store[context];

    if (initialSet) {
      store[context] = {
        data: data,
        refs: [],
      };
      return;
    }

    console.log('new data', data);
    store[context].data = data;
    store[context].refs.forEach((ref) => ref.render());
  },
};

export default store;

// SearchHistory.js

import store from '../../store.js';

export default class SearchHistory {
  constructor($target) {
    this.$target = $target;

    this.$el = document.createElement('ul');
    this.$el.className = 'SearchHistory';

    this.$target.append(this.$el);

    store.set('search-history', []); // create 'search-history' context, initialData is []
    store.subscribe('search-history', this); // subscribe 'search-history' context
  }

  render() { // 'search-history'가 다른 곳에서 업데이트될 경우 render()가 실행됨
    this.$el.innerHTML = store
      .get('search-history') // get 'search-history' context
      .map((searched) => `<li>${searched}</li>`)
      .join('');
  }
}
  • 페이지를 새로고침해도 마지막 검색 결과 화면이 유지되도록 처리합니다.
// localStorage.js

const localStorage = {
  get: (key) => JSON.parse(window.localStorage.getItem(key)),
  set: (key, value) => window.localStorage.setItem(key, JSON.stringify(value)),
};

// SearchInput.js

async updateSearchResult(searchedKeyword) {
  /../
  store.set('search-result', res.data); // store set과 함께
  localStorage.set('cats-search-result', res.data); // localStorage에 최근 'search-result' data 저장해두기
  /../
}

// SearchResult.js
  constructor($parent) {
  /../
    // localStorage에서 initialData 가져오기
    store.set('search-result', localStorage.get('cats-search-result') || []);    
    store.subscribe('search-result', this);
  /../
  }
  • 필수 SearchInput 옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50 을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.
// RandomSearchButton.js

// SearchInput에서의 데이터 처리 과정과 유사
  async updateSearchResult() {
    const loading = new Loading();

    try {
      const { data } = await api.fetchRandomCats();

      store.set('search-result', data);
      localStorage.set('cats-search-result', data);
    } catch (e) {
      console.warn(e);

      new ErrorMessage({
        $target: this.$el,
        message: '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.',
      });
    } finally {
      loading.$el.remove();
      this.$el.disabled = false;
    }
  }

  async onClick() {
    this.$el.disabled = true;
    this.updateSearchResult();
  }

  this.$el.addEventListener('click', this.onClick);
  • lazy load 개념을 이용하여, 이미지가 화면에 보여야 할 시점에 load 되도록 처리해야 합니다.
/* 
  lazyload.js
 
  구현 방법이 다양함
  1. html loading 속성
  2. intersectionObserver
  3. scroll event
  
  img width 100%를 지원하는 intersectionObserver 선택
  
  root : lazyload를 적용할 뷰포트 지정 => document로 지정할 경우 => 웹 브라우저 화면
  rootMargin : 뷰포트 아래로의 마진값 => 이 위치까지 observer callback 실행
  threshold : 뷰포트에서 entry가 보여지는 비율(threshold)에 따라 observer callback이 실행됨 => 1.0일 경우 뷰포트에서 entry 돔이 모두 보여져야 callback이 실행됨
*/

export default function lazyLoad(options = {}) {
  const { root = document, rootMargin = '500px', threshold = 0 } = options;

  var lazyImageWrappers = [].slice.call(
    root.querySelectorAll('.img-wrapper.lazy')
  );

  if ('IntersectionObserver' in window) {
    const observerCallback = (entries) =>
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
          // entry가 뷰포트(root)에 보여질 때(isIntersecting),
          // 이미지 src를 data-src에서 가져오고
          // 이미지가 load될 때 placeholder 제거
            const lazyImage = entry.target.querySelector('img');
            const placeholder = entry.target.querySelector('.img-placeholder');

            lazyImage.src = lazyImage.dataset.src;
            lazyImage.classList.remove('lazy'); // lazy 클래스 제거
            lazyImage.onload = () => {
              placeholder.classList.add('fade-out');
              placeholder.ontransitionend = () => placeholder.remove();
            };
            lazyImageObserver.unobserve(entry.target); // lazy load를 마친 후 entry unobserve하기
          }
        })
        
    let lazyImageObserver = new IntersectionObserver(
      observerCallback,
      {
        root,
        rootMargin,
        threshold,
      }
    );

    lazyImageWrappers.forEach(function (lazyImageWrapper) {
      lazyImageObserver.observe(lazyImageWrapper);
    });
  } else {
    // 미지원일 경우 scrollEvent로 레이지로드 적용
    // Possibly fall back to event handlers here
  }
}
  • 추가 검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.
// SearchResult.js
/*
https://javascript.info/mousemove-mouseover-mouseout-mouseenter-mouseleave#:~:text=The%20mouseover%20event%20occurs%20when,and%20the%20other%20one%20%E2%80%93%20relatedTarget%20.

mouseover/out => event bubbling 발생
mouseleave/enter => event bubbling 발생 x
*/
  onMouseOver(e) {
    const item = e.target.closest('.item'); // 고양이 카드 
    if (!item) return;

    // 페이지 로드 시, 카드 위치에 마우스가 올라가있을 경우 중복되어 item-name이 렌더링되는 현상 방지
    if (item.querySelector('.item-name')) return; 

    const name = item.dataset.name;

    const $itemName = document.createElement('span');
    $itemName.textContent = name;
    $itemName.className = 'item-name';

    item.querySelector('.img-wrapper').append($itemName);
  }

  onMouseOut(e) {
    const item = e.target.closest('.item');
    if (!item) return;

    item.querySelector('.item-name').remove();
  }

코드 구조 관련

  • 필수 API 의 status code 에 따라 에러 메시지를 분리하여 작성해야 합니다.
const API_ENDPOINT =
  'https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev';

const statusErrorFromResponse = (res) => {
  if (res.status < 300) return false;

  if (res.status < 400) {
    return `Redirects Error with status code ${res.status}`;
  }
  if (res.status < 500) {
    return `Client Error with status code ${res.status}`;
  }
  if (res.status < 600) {
    return `Server Error with status code ${res.status}`;
  }
};

const api = {
  fetchCats: async (keyword) => {
    const res = await fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);

    const statusErrorMessage = statusErrorFromResponse(res);
    if (statusErrorMessage) throw new Error(statusErrorMessage);

    return res.json();
  },
  fetchCatById: async (id) => {
    const res = await fetch(`${API_ENDPOINT}/api/cats/${id}`);

    const statusErrorMessage = statusErrorFromResponse(res);
    if (statusErrorMessage) throw new Error(statusErrorMessage);

    return res.json();
  },
  fetchRandomCats: async () => {
    const res = await fetch(`${API_ENDPOINT}/api/cats/random50`);

    const statusErrorMessage = statusErrorFromResponse(res);
    if (statusErrorMessage) throw new Error(statusErrorMessage);

    return res.json();
  },
};

export default api;

추가할 내용

스크롤 페이징 구현

  • 검색 결과 화면에서 유저가 브라우저 스크롤 바를 끝까지 이동시켰을 경우, 그 다음 페이지를 로딩하도록 만들어야 합니다.

랜덤 고양이 배너 섹션 추가

  • 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.
  • 앱이 구동될 때 /api/cats/random50 api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.
  • 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.
  • 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보
profile
띵가띵가

0개의 댓글