Vanilla JS 고양이 사진첩 구현하기

young-gue Park·2023년 2월 10일
0

JavaScript

목록 보기
19/20
post-thumbnail

⚡ Vanilla JS 고양이 사진첩 구현하기


📌 구현 요구 사항

1. 고양이 사진 API를 통해 사진과 폴더를 렌더링한다.
2. 폴더를 클릭하면 내부 폴더의 사진과 폴더를 보여준다.

현재 경로가 어디인지도 렌더링한다.

3. 루트 경로가 아닌 경우, 파일 목록 맨 앞에 뒤로가기를 넣는다.
4. 사진을 클릭하면 고양이 사진을 모달창으로 보여준다.

esc를 누르거나 사진 밖을 클릭하면 모달을 닫는다.

5. API를 불러오는 중인 경우 로딩 중임을 알리는 처리를 한다.

🔹 컴포넌트 구성

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


📌 사용할 API, CSS, 이미지 URL

  1. API : https://cat-photos.edu-api.programmers.co.kr
  2. CSS : https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/css/cat-photos.css
  3. 고양이 이미지 : 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
  4. 파일, 디렉토리, 뒤로가기 이미지 주소
    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

    ❗ 이 API는 AWS를 이용해 제작되었다. 문제는 API가 상당히 불안정하여 어떤 때에는 작동하고 어떤 때에는 작동하지 않는다. (...) CORS Error 해결을 위해 확장프로그램까지 설치했지만 큰 진전은 없었다. 제작자님... 제발...


📌 구현

❗ state 정합성 체크가 아직 들어가지 않은 부분이 있다. 오류로 멈춰서는 부분을 찾아 수정이 진행중이다.

❗ 파일이 열람되지 않는다. 정합성 체크를 통해 현재는 undefined로라도 출력되게 해두었다. 이에 대한 디버깅 역시 진행중이다.

🌟 전체적인 구조

🖥 index.html

<!DOCTYPE html>
<html lang="ko">
<link>
    <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"></link>
</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를 붙이기전에 이곳에서 더미데이터를 통한 시험 운행을 하는 것을 잊지 말아야한다. (당신을 너무 믿지마라...)

🖥 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)
    }
}
  • api를 붙이는 코드는 이제 매크로처럼 나온다...

🖥 Nodes.js

export default function Nodes({ $target, initialState, onClick,  onPrevClick}) {
    const $nodes = document.createElement('div')
    $nodes.classList.add('nodes') // div에 클래스 넣기
    $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 && 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

        if(!id) {
            onPrevClick()
        }
        
        const node = this.state.nodes.find(node => node.id === id)

        // id가 있는 경우와 없는 경우
        if(node) {
            onClick(node)
        } else {
            onPrevClick()
        }
    })
}
  • id 여부에 따라 뒤로가기나 다음 파일로 가기가 가능하며 파일인지 디렉토리인지에 따라 이미지도 달라진다.
  • map 이전에 node가 있는지 &&을 통해 체크하고 들어가면 오류를 막을 수 있다.

🖥 Loading.js

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

    $target.appendChild($loading)
    this.state = false

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

    this.render = () => {
        $loading.innerHTML = `
            <div class = "content">
                <img width="100%" src= "https://cat-photos-dev-serverlessdeploymentbucket-fdpz0swy5qxq.s3.ap-northeast-2.amazonaws.com/public/images/a2i.jpg" alt="Loading..."/>
            </div>
        `

        $loading.style.display = this.state ? 'block' : 'none'
    }

    this.render()
}
  • api 호출을 대기하는 동안 작고 깜찍한 고양이 사진이 우리를 반겨준다.

🖥 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'
        $imageViewer.innerHTML = `
            <div class="content">
                <img src="${this.state.selectedImageUrl}"/>
            </div>
        `
    }

    this.render()

    window.addEventListener('keyup', (e) => {
        // ESC를 눌렀을 때 onClose 호출
        if(e.key === 'Escape') {
            onClose()
        }
    })

    $imageViewer.addEventListener('click', (e) => {
        if(Array.from(e.target.classList).includes('Modal')) {
            onClose()
        }
    })
}
  • url에 따라 이미지가 모달창으로 뜬다. 떠야만 했다. undefined로만 읽고 있는 현실이 야속하다.
  • 모달창은 모달창 외의 다른 곳을 누르거나 ESC를 누르면 닫힌다.

🖥 Breadcrumb.js

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 && this.state.map(({id, name}) => `
                <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 { request } from "./api.js"
import ImageViewer from "./imageViewer.js"
import Nodes from "./Nodes.js"
import Loading from "./Loading.js"
import Breadcrumb from "./Breadcrumb.js"

export default function App ({ $target }) {
    this.state = {
        isRoot: true,
        isLoading: false,
        nodes: [],
        paths: []
    }
    const loading = new Loading({
        $target
    })

    const breadcrumb = new Breadcrumb({
        $target,
        initialState: this.state.paths,
        onClick: async (id) => {
            // 클릭한 경로 외에 paths 날리기
            const nextPaths = id ? [...this.state.paths] : []
            const pathIndex = nextPaths.findIndex(path => path.id === id)

            if(id) {
                const nextPaths = id? [...this.state.paths] : []
                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}`
                })
            }
        },
        onPrevClick: 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({
            imageUrl: 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}` : '/') // id가 있으면 id 기반으로, 없으면 root에서 호출

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

    fetchNodes()
}
  • 모든 컴포넌트들의 매개변수로 들어갈 것들을 모두 이곳에서 정의한다.

🖨 구현 결과

  • 처음 로딩 화면

  • 메인 화면
  • CSS 파일을 만든 제작자가 아무래도 정렬 방향을 세로로 잘못 설정한듯 하다.

  • 노란 고양이를 누른 화면
  • breadcrumb도 정상 작동함을 볼 수 있다.
  • 뒤로가기 버튼 혹은 breadcrumb의 root를 누르면 메인화면으로 돌아간다.

  • 파일을 눌렀을 때 (모달창으로 사진이 떠야 함)
  • 환장할 것 같다. 누군가 문제의 이유를 찾으셨다면 댓글로 남겨주시길 바랍니다...

아직 오류가 해결 되지 않은 부분 중 대부분이 API의 문제인지라... 상당수는 해결이 어려워보인다.
이미지 문제도 이미지 자체 링크의 문제일지도 모르겠다는 생각이 든다...

profile
Hodie mihi, Cras tibi

0개의 댓글