고양이 사진첩 만들기

나혜수·2023년 3월 7일
0

자바스크립트 실전

목록 보기
17/19

고양이 사진첩 만들기

구현 요구사항

  1. 고양이 사진 API를 통해 사진과 폴더를 렌더링한다.
  2. 폴더를 클릭하면 내부 폴더의 사진과 폴더를 보여준다. - 현재 경로가 어딘지도 렌더링한다.
  3. 루트 경로가 아닌 경우, 파일 목록 맨 앞에 뒤로가기를 넣는다.
  4. 사진을 클릭하면 고양이 사진을 모달창으로 보여준다.
    ESC 키를 누르거나 사진 밖을 클릭하면 모달을 닫는다,
  5. API를 불러오는 중인 경우 로딩 중임을 알리는 처리를 한다.

💡 Breadcrumb
브레드크럼이란 헨젤과 그레텔에서 따온 용어로, 사이트나 웹 앱에서 유저의 위치를 보여주는 부차적인 내비게이션을 뜻한다.
전체 구조 안에서 유저가 어디에 있는지 알려주기 용이하며 전체 구조 이해에 도움을 준다.


API

API
❗API가 상당히 불안정하여 어떤 때에는 작동하고 어떤 때에는 작동하지 않는다.
https://cat-photos.edu-api.programmers.co.kr

[
  {
    "id":"1",
    "name":"노란고양이",
    "type":"DIRECTORY",
    "filePath":null,
    "parent":null
  },
 {
    "id":"3",
    "name":"까만고양이",
    "type":"DIRECTORY",
    "filePath":null,
     "parent":null
  }
]

https://cat-photos.edu-api.programmers.co.kr/${id}

[
  {
    "id":"5",
    "name":"2021/04",
    "type":"DIRECTORY",
    "filePath":null,
    "parent":{"id":"1"}
  },
  {
    "id":"19",
    "name":"물 마시는 사진",
    "type":"FILE",
    "filePath":"/images/a2i.jpg",
    "parent":{"id":"1"}
  }
]

CSS
https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/css/cat-photos.css


고양이 이미지
https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/${node.filePath}

예시 : https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/a2i.jpg


파일, 디렉토리, 뒤로가기 이미지 주소

https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/file.png

https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/directory.png

https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/prev.png


코드

index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>고양이 사진첩</title>
    <link rel="stylesheet" href="https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/css/cat-photos.css">
</head>
<body>
    <main class="App"></main>
    <script src="/src/main.js" type="module"></script>  
</body>
</html>

main.js

import App from './App.js'

const $target = document.querySelector('.App')

new App({$target})

Api.js

const API_END_POINT = "https://cat-photos.edu-api.programmers.co.kr/"

export const request = async(url) => {
    try{
        const res = await fetch(`${API_END_POINT}${url}`)
        if(!res.ok){
            throw new Error ('api 호출 오류')
        }
        return await res.json()  
    } catch(e){
        alert(e.message)
    }
}

Nodes.js

export default function Nodes({ $target, initialState, onClick, onPreClick }){
    /*
    initialState: {
                    isRoot : false,
                    nodes : []
                   }, 
    */

    const $nodes = document.createElement('div')
    $nodes.classList.add('Nodes')
    $target.appendChild($nodes)
    
    this.state = initialState

    this.setState = (nextState) => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        const {isRoot, nodes} = this.state

        $nodes.innerHTML = `
        ${isRoot ? '' : `
            <div class="Node">
                <img src="https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/prev.png"/>
            </div>`
        }

        ${nodes.map(node => `
            <div class="Node" data-id="${node.id}">
                <img src="${node.type === 'DIRECTORY' ? 
                "https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/directory.png" : "https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/file.png" }" >
                ${node.name}
            </div>
        `).join(' ')}
    `}

    this.render()

    $nodes.addEventListener('click', (e) => {
        const $node = e.target.closest('.Node')
        const {id} = $node.dataset

        // id가 없는 경우는 뒤로가기를 누른 경우이다. 
        if(!id){
            // 뒤로가기 처리 
        }

        const node = this.state.nodes.find(node => node.id === id)
        if (node){
            onClick(node)
        } else {
            onPreClick()
        }

    })
}

imageViewer.js

export default function ImageViewer({$target,onClose}){
    const $imageViewer = document.createElement('div')
    $imageViewer.className = 'ImageViewer Modal'
    $target.appendChild($imageViewer)

    this.state = {
        selectedImageUrl : null
    }

    this.setState = (nextState) =>{
        this.state = nextState
        this.render()
    }

    this.render = () => {
        $imageViewer.style.display = this.state.selectedImageUrl? 'block' : 'none'

        if(this.state.selectedImageUrl){
            $imageViewer.innerHTML = `
                <div class='content'>
                    <image src='${this.state.selectedImageUrl}'/>
                </div>`    
        }
    }

    this.render()

    // esc 키 누르면 모달이 닫히는 처리 
    window.addEventListener('keyup', e => {
        if(e.key === 'Escape'){
            onClose()
        }
    })

    // 모달창 바깥을 클릭하면 모달창 닫히는 처리 
    $imageViewer.addEventListener('click', e => {
        // includes는 배열에 쓸 수 있으므로 Array.from을 이용해 배열로 감쌈
        if(Array.from(e.target.classList).includes('Modal')){
            onClose()
        }
    })
}

Loading.js

export default function Loading({$target}){
    const $loading = document.createElement('div')
    $loading.className = 'Loading' 
    $target.appendChild($loading)

    this.state = false

    this.setState = nextState => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        $loading.innerHTML = `
        <div class = 'Modal' style="text-align:center">
            <img src="https://media.giphy.com/media/gx54W1mSpeYMg/giphy.gif" alt="loading...">
        </div> 
        `
        
        $loading.style.display = this.state? 'block' : 'none'
    
    }

    this.render()
}

export default function BreadCrumb({$target, initialState, onClick}){
    const $breadCrumb = document.createElement('nav')
    $breadCrumb.className = 'Breadcrumb'
    $target.appendChild($breadCrumb)

    this.state = initialState

    this.setState = nextState => {
        this.state = nextState
        this.render()
    }

    this.render = () => {
        $breadCrumb.innerHTML = `
        <div class = "breadcrumb_item"> root <div>
        ${this.state.map(({name, id})=>
            `<div class = "breadcrumb_item" data-id="${id}">- ${name}</div>`
        ).join(' ')}
        `
    }

    this.render()

    $breadCrumb.addEventListener('click', (e)=> {
        const breadcrumbItem = e.target.closest('.breadcrumb_item')
        const {id} = breadcrumbItem.dataset
        onClick(id)
    })
}

App.js

import Nodes from './Nodes.js'
import { request } from './Api.js' 
import ImageViewer from './imageViewer.js'
import Loading from './Loading.js'
import BreadCrumb from './breadCrumb.js'

export default function App({$target}){

    this.state = {
        isRoot: true,
        nodes: [],
        paths: [],
        isLoading: false
    }

    const loading = new Loading({$target})

    const breadCrumb = new BreadCrumb({
        $target,
        initialState: this.state.paths,
        onClick: async(id) => {
            // 클릭한 경로 외에 path를 날려준다. 
            if(id) {
                const nextPaths = [...this.state.paths] 
                const pathIndex = nextPaths.findIndex(path => path.id === id)
                this.setState({
                    ...this.state,
                    paths: nextPaths.slice(0, pathIndex + 1)
                })
            } else {
                this.setState({
                    ...this.state,
                    paths: []
                })
            }

            // 클릭한 경로로 이동 
            await fetchNodes(id)
        }
    })

    const nodes = new Nodes({
        $target,
        initialState: {
            isRoot : this.state.isRoot,
            nodes : this.state.nodes,
            selectedImageUrl: null
        },
        onClick: async(node) => {
            if(node.type === 'DIRECTORY'){
                await fetchNodes(node.id)

                this.setState({
                    ...this.state,
                    paths: [...this.state.paths, node]
                })
            }

            if(node.type === 'FILE'){
                this.setState({
                    ...this.state,
                    selectedImageUrl: `https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public${node.filePath}`
                })
            }   
        },
        onPreClick: async() => {
            const nextPaths = [...this.state.paths]
            

            nextPaths.pop()
            this.setState({
                ...this.state,
                paths: nextPaths
            })

            if (nextPaths.length === 0 ){
                await fetchNodes()
            } else{
                await fetchNodes(nextPaths[nextPaths.length-1].id)
            }
        }
    })

    const imageViewer = new ImageViewer({
        $target,
        onClose: () => {
            this.setState({
                ...this.state,
                selectedImageUrl: null
            })
        }
    })

    this.setState = (nextState) =>{
        this.state = nextState
        nodes.setState({
            isRoot : this.state.isRoot,
            nodes : this.state.nodes,
        })

        imageViewer.setState({
            ...this.state,
            selectedImageUrl : this.state.selectedImageUrl
        })

        loading.setState(this.state.isLoading)

        breadCrumb.setState(this.state.paths)
    }


    const fetchNodes = async(id) => {
        this.setState({
            ...this.state,
            isLoading: true
        })
        const nodes = await request(id? `${id}` : '')

        this.setState({
            ...this.state,
            nodes,
            isRoot: id? false : true,
            isLoading: false
        })
    }

    fetchNodes()

}

결과

⭐ 요구사항을 보고 어떤 컴포넌트를 만들고 조합할지 정리한 후 코드를 작성하자!!
CSS 문제로 breadCrumb가 root 옆으로 붙지 않는 것을 제외하고는 정상적으로 작동한다. 다만 API가 매우 불안정하여 로딩이 오래걸리며 fetch에 실패하는 경우가 매우 많다. (502 에러)

profile
오늘도 신나개 🐶

0개의 댓글