[프로그래머스] 고양이 사진첩 만들기 - 2편(필수 구현)

coderH·2022년 7월 6일
0

프로그래머스과제

목록 보기
2/3
post-thumbnail

고양이 사진첩 만들기 풀이 2편

이번 2편에서는 필수 구현사항에 대한 풀이를 다뤄보겠습니다.

컴포넌트는 클래스형으로 작성하였으며 가산점을 위해 컴포넌트들은 모듈 형태로 사용합니다.

기본 구조

App 클래스에 렌더링

먼저 src폴더 내에 index.jsApp.js 파일을 생성하고
App.js 파일에서 App컴포넌트를 생성합니다.

index.js에서는 document.querySelector(".App")을 통해 App클래스를 가진 요소를 인자를 넘겨주어 App컴포넌트가 해당 요소를 기준으로 컴포넌트들을 렌더링할 수 있도록 합니다.

index.js

import App from "./App.js";

new App(document.querySelector(".App")); 

App.js

export default class App {
    constructor($target) {

    }
} 

ES6 모듈 형태를 사용하므로 꼭 import문에서 파일의 확장자를 적어주어야 합니다.

컴포넌트 3개로 나누기

먼저 유의사항에 맞게 컴포넌트를 BreadCrumb, Nodes, ImageView 3개로 나눠주고
이는 App.js 가 import하여 App클래스를 가진 요소에 렌더링할 수 있도록 합니다.

각 컴포넌트의 $target의 값은 .App 요소가 됩니다.

BradCrumb.js

export default class Breadcrumb {
    constructor($target) {
        this.nav = document.createElement("nav");
        this.nav.className = "Breadcrumb";
        
        $target.appendChild(this.nav);

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

    render() {
        this.nav.innerHTML = `
            <div>root</div>
            <div>노란고양이</div>
        `;
    }
}

Nodes.js

export default class Nodes {
    constructor($target) {
        this.nodes = document.createElement("div");
        this.nodes.className = "Nodes";
        
        $target.appendChild(this.nodes);

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

    render() {
        this.nodes.innerHTML = `
            <div class="Node">
                <img src="./assets/prev.png">
            </div>
            <div class="Node">
                <img src="./assets/directory.png">
                <div>2021/04</div>
            </div>
            <div class="Node">
                <img src="./assets/file.png">
                <div>하품하는 사진</div>
            </div>
        `;
    }
}

ImageView.js

export default class ImageView {
    constructor($target) {
        this.modal = document.createElement("div");
        this.modal.className = "Modal ImageViewer";

        $target.appendChild(this.modal);

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

    render() {
        this.modal.innerHTML = `
            <div class="content">
            <img src="./assets/sample_image.jpg">
        `;
    }
}

App.js

import Breadcrumb from "./components/BreadCrumb.js";
import ImageView from "./components/ImageView.js";
import Nodes from "./components/Nodes.js";

export default class App {
    constructor($target) {

        this.breadCrumb = new Breadcrumb($target);
        this.nodes = new Nodes($target);
        this.imageView = new ImageView($target);
    }
}

API

API관련 함수는 별도의 모듈로 작성해야 하므로 services 디렉토리에 api.js 파일을 생성합니다.

API의 END_POINT는 이 파일 내부에서만 사용할 것이므로 const 변수로 선언해주고
API 통신 로직은 응답을 받은 뒤 해당 값을 반환할 수 있도록 async await구문을 넣어줍니다.

api.js

const END_POINT = "https://zl3m4qq0l9.execute-api.ap-northeast-2.amazonaws.com/dev";

export const request = async (nodeId) => {
    const targetURL = nodeId ? `${END_POINT}/${nodeId}` : END_POINT;
	const res = await fetch(targetURL);
    
	if (res.ok) {
		return res.json();
	}
}

외부 컴포넌트에서는 request함수를 통해 요청만 하면 되기 때문에 함수만 export를 해줍니다.

Nodes

필수 조건

  • 노드 클릭시 타입에 따라 알맞은 일이 일어나야 한다.
    • 디렉토리 클릭시 하위에 속한 디렉토리와 파일들을 불러와 렌더링한다.
    • 파일 클릭시 노드의 filePath값을 이용해 이미지를 불러와 렌더링한다.
  • root 경로가 아닌 경우 Nodes 목록 맨 왼쪽에 이전 디렉토리로 이동할 수 있는 기능을 구현해야 한다.

먼저 API 응답에 따라 파일 목록이 렌더링 될 수 있도록 코드를 작성해줘야 합니다.

App컴포넌트에 items라는 이름의 state를 추가하여 Nodes컴포넌트에 내려주고
setState시에도 변경된 값으로 리렌더링 될 수 있도록 작성해줍니다.

또한 최초 렌더링시에는 init함수를 통해 root디렉토리를 가져올 수 있도록 코드를 작성해줍니다.

App.js

export default class App {
    constructor($app) {
        this.state = {
            items: []
        };
        
        this.nodes = new Nodes({ $app, initialState: this.state.items});
    }
    
    ===========================================
    
    setState(nextState) {
    	this.state = nextState;
        this.nodes.setState(this.state.items);
    }
    
    ===========================================
        
	init = async () => {
    	try {
			const data = await request();

            if (data) {
                this.setState({
                    ...this.state,
                    items: data
                });
            }
        } catch(e) {
        	throw new Error(e);
        }
    }
}

Nodes.js

export default class Nodes {
    constructor({ $app, initialState }) {
        this.state = initialState;
	}
    
    ===========================================
    
    setState(nextState) {
    	this.state = nextState;
    }
    
    render() {
    	this.state.map(({id, name, type, filePath}) => {
			if (type === "DIRECTORY") {
				return `
					<div data-id=${id} data-type=${type} class="Node">
						<img src="./assets/directory.png">
						<div>${name}</div>
					</div>
				`;
			} else {
				return `
					<div data-id=${id} data-type=${type} data-src=${filePath} class="Node">
						<img src="./assets/file.png">
						<div>${name}</div>
					</div>
				`;
                }
        }.join(""));
    }

map메소드를 이용해 items 배열을 순환하면서 type에 따라 적절하게 렌더링될 수 있도록 return값을 처리해줍니다.

노드 클릭 이벤트

이벤트 바인딩을 최소화해야 한다는 가산점을 받기 위해 이벤트 위임 방식을 사용합니다.
.Nodes 요소에만 하나의 이벤트를 등록하여 해당 범위내에서 발생하는 모든 클릭이벤트를 관리합니다.

해당 이벤트의 콜백 함수는 이벤트를 감지하여 타겟이 directory인지 file인지 확인해야 합니다.

directory라면 request함수를 호출하여 items를 업데이트하고 file이라면 이미지를 띄울 수 있도록
App컴포넌트에서 Nodes컴포넌트로 onClick 콜백함수를 전달합니다.

App.js

this.nodes = new Nodes({ 
	$target, 
	onClick: target => {
    	const targetId = target.dataset.id;
        const currentNode = this.state.path[this.state.path.length-1];
                
		if(targetId !== currentNode.id) {
        	const targetIndex = parseInt(target.dataset.index);
            const path = [...this.state.path];
            path.splice(targetIndex + 1);

			this.setState({
				...this.state,
                items: this.cache[targetId],
                isRoot: path.length === 1 ? true : false,
                path
			});
		}
	}
});

이후 Nodes컴포넌트에서 onClick 함수를 인자로 받아 .Nodes요소에 addEventListener로 클릭 이벤트를 등록해줍니다.

Nodes.js

export default class Nodes {
    constructor({ $app, initialState, onClick }) {
        this.state = initialState;

        this.target = document.createElement("div");
        this.target.className = "Nodes";
        this.target.addEventListener("click", e => {
            const target = e.target.closest(".Node");

            if (target) {
                onClick(target);
            }
        });
        
        $app.appendChild(this.target);

        this.render();
    }

이벤트리스너 내부의 로직을 잠깐 살펴보자면 closest메소드는 이벤트가 발생했을 때
자신을 포함한 상위요소 중 인자로 들어온 선택자에 맞는 요소가 있을 경우 해당 요소를 반환하는 메소드입니다.

따라서 .Node 요소의 하위에 있는 img태그 혹은 디렉토리 및 파일의 이름이 포함된 div태그가 클릭되더라도 .Node 클래스를 가진 요소를 특정할 수 있습니다.

이 메소드를 통해 알맞는 .Node요소target으로 잡아내고 없다면 애초에 onClick메소드가 호출되지 않도록 합니다.

뒤로가기 화살표 구현

뒤로가기 버튼은 현재 디렉토리가 root일 경우 표시되지 않도록 해야하므로
현재 디렉토리가 root인지의 여부를 알려줄 수 있는 isRoot 라는 state를 App컴포넌트에서 추가해주겠습니다.

App.js

export default class App {
    constructor($app) {
        this.state = {
            items: [],
            isRoot: true,
        };

위에서 items를 추가했던것처럼 Nodes컴포넌트에 isRoot를 전달합니다.

따라서 Nodes 컴포넌트는 itemsisRoot를 state로 가지며
isRoot의 값을 기준으로 render함수 호출시 조건부로 렌더링될 수 있도록 작성합니다.

뒤로가기 버튼은 Nodes 내부에서만 쓰이기 때문에 최상단에 const변수로 템플릿을 정의해놓겠습니다.

Nodes.js

const PREVBTN_TEMPLATE = `<div data-type="PREVBTN" class="Node"><img src="./assets/prev.png"></div>`;

export default class Nodes {
    constructor({ $app, initialState, onClick, onBackClick }) { // onBackClick 추가
        this.state = initialState;

        this.target = document.createElement("div");
        this.target.className = "Nodes";
        this.target.addEventListener("click", e => {
            const target = e.target.closest(".Node");

            if (target) {								
                target.dataset.type === "PREVBTN" // type에 맞는 함수를 호출할 수 있도록 if문 사용
                ? onBackClick() 
                : onClick(target);
            }
        });
        
---------------------------------------------------------

  render() {
	const nodes = `
		${this.state.items.map(({id, name, type, filePath}) => {
			if (type === "DIRECTORY") {
				return `
					<div data-id=${id} data-type=${type} class="Node">
						<img src="./assets/directory.png">
						<div>${name}</div>
					</div>
				`;
			} else {
				return `
					<div data-id=${id} data-type=${type} data-src=${filePath} class="Node">
						<img src="./assets/file.png">
                        <div>${name}</div>
                    </div>
				`;
			}
		}).join("")}
	`;
			
	// isRoot가 false일 때 PREVBTN추가         
	this.target.innerHTML = this.state.isRoot ? nodes : `${PREVBTN_TEMPLATE}${nodes}`;
}

ImageView

필수 조건

  • 파일 클릭시 Modal을 띄우며 파일의 이미지를 렌더링한다.

Modal을 띄우며 파일 이미지를 렌더링

App컴포넌트에 클릭된 파일의 경로를 알아야하므로 selectedImage라는 state를 추가하여
ImageView 컴포넌트로 전달합니다.

이는 Nodes의 onClick이벤트 내부에서 type이 파일일경우 해당 파일의 filePathselectedImage의 값으로 할당하고 ImageView에서는 이 값을 기준으로 모달을 제어합니다.

App.js

export default class App {
    constructor($app) {
        this.state = {
            items: [],
            selectedImage: null,  // selectedImage 추가
            isRoot: true
        };
        
----------------------------------------------

        this.nodes = new Nodes({
            $app,
            initialState: {
                items: this.state.items, 
                isRoot: this.state.isRoot 
            },
            onClick: async (target) => {
                if (target.dataset.type === "DIRECTORY") {
                    this.setState({
                        ...this.state,
                        isLoading: true
                    });

                    const name = target.innerText;
                    const id = target.dataset.id;
                    let data;
                    
                    try {
                        const path = [...this.state.path];
                        path.push({name, id});

                        if (this.cache[id]) {
                            data = this.cache[id]
                        } else {
                            data = await request(id);
                            this.cache[id] = data;
                        }

                        this.setState({
                            ...this.state,
                            items: data,
                            isRoot: false,
                            path
                        });
                    } catch (e) {
                        alert("에러가 발생했습니다.");
                        throw new Error(`에러가 발생했습니다. ${e.message}`);
                    } finally {
                        this.setState({
                            ...this.state,
                            isLoading: false
                        });
                    }
                } else if(target.dataset.type === "FILE") {
                	// 파일을 클릭했을 때 해당 이미지의 filePath를 selectedImage의 값으로 할당
                    this.setState({
                        ...this.state,
                        selectedImage: target.dataset.src
                    });
                } else {}
		

ImageVIew.js

export default class ImageView {
    constructor({ $app, initialState }) {
        this.state = initialState;
        
        this.target = document.createElement("div");
        this.target.className = "Modal ImageViewer";

        $app.appendChild(this.target);

        this.render();
    }
	
	render() {
		if (this.state) {
            this.target.style.visibility = "visible";
            this.target.innerHTML = `
                <div class="content">
                    <img src=${BASE_URL}${this.state}>
                <div>
            `;
        } else {
            this.target.style.visibility = "hidden";
        }
    }
}

BreadCrumb

필수 조건

  • root를 가장 왼쪽에 넣어야하며 탐색하는 폴더 순서대로 경로를 나타낸다.
  • 디렉토리 이동에 따라 Breadcrumb 영역에도 탐색한 디렉토리 순서에 맞게 업데이트 되어야 한다.

탐색하는 순서대로 경로 나타내기

App컴포넌트의 state에서 경로를 나타내기 위한 path라는 state를 추가합니다.

App.js

export default class App {
    constructor($app) {
        this.state = {
            items: [],
            path: [],
            selectedImage: null,
            isRoot: true
        };

pathBreadCrumb의 state가 되며 init함수에서 성공적으로 응답을 받으면 path 에 root가 추가되도록 합니다.

BreadCrumb.js

export default class Breadcrumb {
    constructor({ $app, initialState }) {
        this.state = initialState;

        this.target = document.createElement("nav");
        this.target.className = "Breadcrumb";
        
        $app.appendChild(this.target);

        this.render();
    }

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

    render() {
        this.target.innerHTML = `
            ${this.state.map(({name}, index) => {
                return `<div data-index=${index}>${name}</div>`;
            }).join("")}
        `;
    }
}

App.js

init = async () => {
    this.setState({
        ...this.state,
        isLoading: true
    });

    try {
        const data = await request();

        if (data) {
            this.setState({
                ...this.state,
                items: data,
                path: [{
                    name: "root"
                }],
                isRoot: true
            });
        }
    } catch (e) {
        throw new Error(`에러가 발생했습니다. ${e.message}`);
    } finally {
        this.setState({
            ...this.state,
            isLoading: false
        });
    }
}

여기까지 필수 구현사항에 대한 풀이를 해보았습니다.
다음 편에서는 옵션 구현사항과 가산점 항목에 대해서 풀이해보겠습니다.

출처

고양이 사진첩 만들기 과제 | 프로그래머스

0개의 댓글