diff --git a/.eslintrc.json b/.eslintrc.json
index 48b77563c..10c183d89 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -13,14 +13,20 @@
"sourceType": "module"
},
"rules": {
+ "no-new": 0,
"no-var": "error",
"max-depth": ["error", 2],
+ "no-alert": 0,
+ "default-case": 0,
"no-console": "warn",
"no-param-reassign": "error",
"no-undefined": 0,
"no-constant-condition": 0,
"no-unused-private-class-members": 0,
+ "class-methods-use-this": 0,
"lines-between-class-members": 0,
- "no-useless-escape": 0
+ "no-useless-escape": 0,
+ "import/no-unresolved": 0,
+ "import/extensions": 0
}
}
diff --git a/README.md b/README.md
index b4241f899..2663d50db 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
### 데모 페이지
+
[데모 페이지](https://usageness.github.io/javascript-youtube-classroom/)
---
@@ -20,6 +21,8 @@
---
+### Step 1
+
- [x] 메인 화면에서 검색 버튼을 누르면 검색 모달창이 나타난다.
- [x] 유튜브 검색 API를 사용해 내가 보고 싶은 영상들을 검색할 수 있다.
- 엔터키를 눌러 검색할 수 있다.
@@ -34,19 +37,33 @@
- 저장 가능한 최대 동영상의 갯수는 100개이다.
- [x] 이미 저장된 영상이라면 저장 버튼이 보이지 않도록 한다.
+### Step 2
+
+- [x] 가장 처음에는 저장된 영상이 없으므로, 비어있다는 것을 사용자에게 알려주는 상태를 보여준다.
+- [x] 이후 페이지를 방문했을 때 기본 메인 화면은 내가 볼 영상들의 리스트를 보여준다.
+- [x] 영상 카드의 이모지 버튼을 클릭하여 아래와 같은 상태 변경이 가능해야 한다.
+ - ✅ 본 영상으로 체크
+ - 🗑️ 버튼으로 저장된 리스트에서 삭제할 수 있다. (삭제 시 사용자에게 정말 삭제할 것인지 물어봅니다.)
+- [x] 본 영상, 볼 영상 버튼을 눌러 필터링 할 수 있다.
+- [x] 반응형 웹: 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 변경한다.
+ - 1280px 이상: 4개
+ - 960px이상~1280px 미만: 3개
+ - 600px이상~960px 미만: 2개
+ - 600px 미만: 1개
+
## 테스트 요구사항
---
- [x] 단위 테스트를 Jest로 작성한다.
- [x] E2E 테스트를 Cypress로 작성한다.
+ - 검증이 필요하다고 생각되는 플로우를 1가지 설정하고 이에 대한 검증을 E2E 테스트로 진행한다.
## 배포
---
- [x] 실행 가능한 페이지에 접근할 수 있도록 github page 기능을 이용하고, 해당 링크를 PR과 README에 작성한다.
-- API key를 public repo에 올리지 않은 채로 데모 페이지를 배포하려면, 별도의 설정이 추가로 필요합니다.
## 📝 License
diff --git a/cypress/integration/app.test.js b/cypress/integration/app.test.js
index 840bb2186..cae9f5368 100644
--- a/cypress/integration/app.test.js
+++ b/cypress/integration/app.test.js
@@ -1,35 +1,26 @@
import { ITEMS_PER_REQUEST } from "../../src/js/constants/constants";
-describe("보고싶은 영상 찾기 모달창 전체 로직 테스트", () => {
+describe("나만의 유튜브 강의실 전체 플로우 테스트", () => {
before(() => {
cy.visit("./index.html");
});
const searchKeyword = "xooos";
- const errorSearchKeyword = `\!\@\!\@\$\!\%\@\$\^\%\&\$\^\*\%\!\@\!\$\!\%\&\(\^\*\%\$\!\@!@$$!#@!#)_)&_%^_)&%_^)&_@!@#!#$@#$%$@#^%&$%^$@$^#%&$%^$^%*$^&^@#$@#$@#%@#$^#%&^**#^#$%@#$@#$^@#$!$@#%@#$%#$^#$%^$%@#$!@#!@#%)^_&)%_^$%#$%#$^#%^#%^#^&_%^_)&_#$)%_)#_$)%#_$%!@#!@$#$!#@!#)_)&_%^_)&%_^)&_%)^_&)%_^&_%^_)&_#$)%_)#_$)%#_$\%`;
+
+ it("처음 실행하면 저장된 영상이 없다는 안내 문구가 나타난다.", () => {
+ cy.get(".no-result__description").should("be.visible");
+ });
it("초기 화면에서 검색 버튼을 누르면 보고싶은 영상 찾기 모달창이 나타난다.", () => {
cy.openSearchModal();
cy.get(".modal-container").should("be.visible");
});
- /**
- * youtube에 검색한 결과가 없는 경우를 찾기 어려움... (테스트 통과가 되지 않을 확률이 높다.)
- */
- // it("보고싶은 영상 찾기 모달창 안에서 원하는 영상을 검색한 결과가 없는 경우 검색 결과 없음 이미지를 보여준다.", () => {
- // cy.get("#search-input-keyword").type(errorSearchKeyword);
- // cy.get("#search-button").click();
- // cy.get(".search-result--no-result").should("be.visible");
- // });
-
- /**
- * 실제 API 호출 했을 경우 주석을 제거 후 테스트를 돌려주세요.
- */
- // it("보고싶은 영상 찾기 모달창 안에서 검색된 영상을 불러오는 동안 로딩 이미지를 보여준다.", () => {
- // cy.get("#search-input-keyword").clear().type(searchKeyword);
- // cy.get("#search-button").click();
- // cy.get(".skeleton").should("be.visible");
- // });
+ it("보고싶은 영상 찾기 모달창 안에서 검색된 영상을 불러오는 동안 로딩 이미지를 보여준다.", () => {
+ cy.get("#search-input-keyword").clear().type(searchKeyword);
+ cy.get("#search-button").click();
+ cy.get(".skeleton").should("be.visible");
+ });
it("보고싶은 영상 찾기 모달창 안에서 검색창에 검색어를 입력하지 않으면 에러 메시지를 보여준다.", () => {
cy.searchWithNoKeyword();
@@ -53,7 +44,6 @@ describe("보고싶은 영상 찾기 모달창 전체 로직 테스트", () => {
});
it("보고싶은 영상 찾기 모달창 안에서 검색된 영상이 이미 저장된 영상이라면 저장 버튼이 보이지 않아야 한다.", () => {
- cy.searchWithKeyword(searchKeyword);
cy.get(".video-item__save-button").eq(0).should("be.not.visible");
});
@@ -61,4 +51,34 @@ describe("보고싶은 영상 찾기 모달창 전체 로직 테스트", () => {
cy.closeSearchModal();
cy.get(".modal-container").should("be.not.visible");
});
+
+ it("초기 화면에서 저장된 영상을 확인할 수 있어야 한다.", () => {
+ cy.get(".video-item").should("be.visible");
+ });
+
+ it("볼 영상 섹션에서 저장된 영상의 ✅ 버튼을 눌러 본 영상 섹션으로 옮길 수 있어야 한다.", () => {
+ cy.clickVideoWatchButton();
+ cy.get(".video-item").should("be.not.visible");
+ });
+
+ it("초기 화면에서 본 영상 버튼을 눌러 본 영상 목록을 확인할 수 있어야 한다.", () => {
+ cy.clickWatchedVideoListTab();
+ cy.get(".video-item").should("be.visible");
+ });
+
+ it("본 영상 섹션에서 저장된 영상의 ✅ 버튼을 눌러 볼 영상 섹션으로 옮길 수 있어야 한다.", () => {
+ cy.clickVideoWatchButton();
+ cy.clickWatchLaterVideoListTab();
+ cy.get(".video-item").should("be.visible");
+ });
+
+ it("초기 화면에서 저장된 영상의 🗑️ 버튼을 눌러 영상을 삭제할지 확인할 수 있어야 한다.", () => {
+ cy.clickVideoDeleteButton(false);
+ cy.get(".video-item").should("be.visible");
+ });
+
+ it("영상을 삭제할지 확인하는 화면에서 확인을 눌러 삭제할 수 있어야 한다.", () => {
+ cy.clickVideoDeleteButton(true);
+ cy.get(".video-item").should("be.not.visible");
+ });
});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 4c79e443f..6626a8467 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -9,9 +9,11 @@ Cypress.Commands.add("searchWithNoKeyword", () => {
cy.on("window:alert", alertStub);
cy.get("#search-input-keyword").clear().type(" ");
- cy.get("#search-button").click(() => {
- expect(alertStub).to.be.calledWith(ERROR_MESSAGE.SEARCH_INPUT_IS_EMPTY);
- });
+ cy.get("#search-button")
+ .click()
+ .then(() => {
+ expect(alertStub).to.be.calledWith(ERROR_MESSAGE.SEARCH_INPUT_IS_EMPTY);
+ });
});
Cypress.Commands.add("searchWithKeyword", (keyword) => {
@@ -30,3 +32,25 @@ Cypress.Commands.add("loadMoreVideos", () => {
Cypress.Commands.add("closeSearchModal", () => {
cy.get(".dimmer").click({ force: true });
});
+
+Cypress.Commands.add("clickWatchedVideoListTab", () => {
+ cy.get("#watched-video-button").click();
+});
+
+Cypress.Commands.add("clickWatchLaterVideoListTab", () => {
+ cy.get("#watch-later-video-button").click();
+});
+
+Cypress.Commands.add("clickVideoWatchButton", () => {
+ cy.get(".video-item__watched-button").eq(0).click();
+});
+
+Cypress.Commands.add("clickVideoDeleteButton", (confirmButtonClick) => {
+ cy.get(".video-item__delete-button").eq(0).click();
+
+ cy.on("window:confirm", (text) => {
+ console.log(text);
+ expect(text).to.contains("정말로 삭제하시겠습니까?");
+ return confirmButtonClick;
+ });
+});
diff --git a/index.html b/index.html
index ced1e263f..8c2fe4d2a 100644
--- a/index.html
+++ b/index.html
@@ -9,12 +9,34 @@
👩🏻💻 나만의 유튜브 강의실 👨🏻💻
+
+ 👁️ 볼 영상
+
+
+ ✅ 본 영상
+
🔍 검색
+
+ 저장된 영상
+
+
+
+
+ 저장된 영상이 없습니다 나만의 영상을 검색하여 저장해보세요
+
+
+
-
+
검색어 입력
검색 결과
+
+
+
+ 검색 결과가 없습니다 다른 키워드로 검색해보세요
+
+
diff --git a/package.json b/package.json
index bb088b4a4..abdfbc39a 100644
--- a/package.json
+++ b/package.json
@@ -15,15 +15,15 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/kkojae91/javascript-youtube-classroom.git"
+ "url": "git+https://github.com/usageness/javascript-youtube-classroom.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
- "url": "https://github.com/kkojae91/javascript-youtube-classroom/issues"
+ "url": "https://github.com/usageness/javascript-youtube-classroom/issues"
},
- "homepage": "https://github.com/kkojae91/javascript-youtube-classroom#readme",
+ "homepage": "https://usageness.github.io/javascript-youtube-classroom/",
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/plugin-transform-runtime": "^7.17.0",
diff --git a/src/css/app.css b/src/css/app.css
index 6a13d3439..af25a734a 100644
--- a/src/css/app.css
+++ b/src/css/app.css
@@ -11,7 +11,6 @@ body {
.classroom-container__title {
text-align: center;
font-weight: bold;
- font-size: 34px;
line-height: 36px;
margin-bottom: 64px;
}
@@ -19,26 +18,49 @@ body {
.nav {
display: flex;
justify-content: center;
+ margin-bottom: 40px;
}
-.button {
- cursor: pointer;
- border-radius: 4px;
- border: none;
- font-style: normal;
- font-weight: bold;
- font-size: 14px;
- letter-spacing: 1.25px;
+.nav__button:hover {
+ background: #ebebeb;
}
-.nav__button {
- width: 80px;
- height: 36px;
- background: #f5f5f5;
+#search-modal-button {
+ margin-left: auto;
}
-.nav__button:hover {
- background: #ebebeb;
+.saved-video__button {
+ border: 1px solid #cccccc;
+}
+
+.saved-video__button:hover {
+ background: rgba(0, 188, 212, 0.12);
+}
+
+.selected {
+ background: rgba(0, 188, 212, 0.12);
+}
+
+.saved-video-list {
+ width: 105%;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 32px 20px;
+ overflow-y: hidden;
+}
+
+.video-button__wrapper {
+ text-align: right;
+}
+
+.no-result {
+ flex-grow: 1;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
}
/* ******** */
diff --git a/src/css/index.css b/src/css/index.css
index bf06b3aee..b5c17b520 100644
--- a/src/css/index.css
+++ b/src/css/index.css
@@ -19,8 +19,79 @@ body {
}
input,
-button,
textarea,
select {
font: inherit;
}
+
+button {
+ height: 36px;
+ cursor: pointer;
+ font-style: normal;
+ font-weight: bold;
+ padding: 0 10px;
+ border-radius: 4px;
+ border: none;
+ background: #f5f5f5;
+ font-size: 14px;
+ letter-spacing: 1.25px;
+}
+
+h1 {
+ font-size: 34px;
+}
+
+h2 {
+ font-size: 34px;
+}
+
+a {
+ text-decoration: none;
+ color: #000000;
+ font: inherit;
+}
+
+@media (max-width: 1279px) {
+ #app {
+ max-width: 760px;
+ }
+
+ .search-modal {
+ max-width: 860px;
+ }
+}
+
+@media (max-width: 959px) {
+ #app {
+ max-width: 500px;
+ }
+
+ .search-modal {
+ max-width: 600px;
+ }
+}
+
+@media (max-width: 599px) {
+ #app {
+ max-width: 300px;
+ }
+
+ .search-modal {
+ max-width: 360px;
+ }
+
+ h1,
+ h2 {
+ font-size: 22px;
+ }
+
+ button {
+ height: 28px;
+ font-size: 12px;
+ }
+
+ .search-input__keyword {
+ width: 200px;
+ font-size: 10px;
+ }
+}
diff --git a/src/css/modal.css b/src/css/modal.css
index 84f579fda..d9c18c615 100644
--- a/src/css/modal.css
+++ b/src/css/modal.css
@@ -9,8 +9,7 @@
left: 0;
}
-.video-item__save-button.hide,
-.modal-container.hide {
+.hide {
display: none;
}
@@ -36,7 +35,6 @@
.search-modal__title {
font-weight: bold;
- font-size: 34px;
line-height: 36px;
text-align: center;
margin-bottom: 40px;
@@ -69,14 +67,6 @@
color: #ffffff;
}
-.search-result.search-result--no-result {
- flex-grow: 1;
-
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
.no-result__image {
width: 190px;
height: 140px;
@@ -91,7 +81,7 @@
}
.video-list {
- width: 1040px;
+ width: 102%;
height: 493px;
display: flex;
flex-direction: row;
diff --git a/src/js/VideoStorage.js b/src/js/VideoStorage.js
index 3b74003c1..879ce5ef2 100644
--- a/src/js/VideoStorage.js
+++ b/src/js/VideoStorage.js
@@ -1,24 +1,46 @@
import { ERROR_MESSAGE, STORAGE_MAX_COUNT } from "./constants/constants";
export default class VideoStorage {
- constructor() {
- this.videos = JSON.parse(localStorage.getItem("videos")) || [];
- }
+ #videos = JSON.parse(localStorage.getItem("videos")) || [];
- isSavedVideoId(responseId) {
- return this.videos.includes(responseId);
+ checkTypeVideoEmpty(isWatchedVideoOnly) {
+ return !this.getVideos().some(
+ (item) => item.isWatched === isWatchedVideoOnly
+ );
}
addVideoData(data) {
- if (this.videos.length >= STORAGE_MAX_COUNT) {
+ if (this.#videos.length >= STORAGE_MAX_COUNT) {
throw new Error(ERROR_MESSAGE.VIDEO_STORAGE_OVERFLOW);
}
- this.videos = [...this.videos, data];
- localStorage.setItem("videos", JSON.stringify(this.videos));
+ this.#videos = [...this.#videos, data];
+ localStorage.setItem("videos", JSON.stringify(this.#videos));
+ }
+
+ getVideos() {
+ return this.#videos;
+ }
+
+ getVideoIdArray() {
+ return this.getVideos().map((item) => item.videoId);
}
- getStorage() {
- return this.videos;
+ setVideoStateWatched(target) {
+ const targetIndex = this.getVideos().findIndex(
+ (item) => item.videoId === target
+ );
+
+ this.#videos[targetIndex].isWatched = !this.#videos[targetIndex].isWatched;
+ localStorage.setItem("videos", JSON.stringify(this.#videos));
+ }
+
+ deleteVideo(target) {
+ const targetIndex = this.getVideos().findIndex(
+ (item) => item.videoId === target
+ );
+
+ this.#videos.splice(targetIndex, 1);
+ localStorage.setItem("videos", JSON.stringify(this.#videos));
}
}
diff --git a/src/js/YoutubeApp.js b/src/js/YoutubeApp.js
index 69783b762..c2a677153 100644
--- a/src/js/YoutubeApp.js
+++ b/src/js/YoutubeApp.js
@@ -1,21 +1,37 @@
import VideoStorage from "./VideoStorage";
import SearchModalView from "./view/SearchModalView";
-import mockObject from "./mockObject";
+import VideoStorageView from "./view/VideoStorageView";
import getSearchResult from "./api/getSearchResult";
import { DELAY_TIME } from "./constants/constants";
import { throttle, checkKeywordValid, isScrollToBottom } from "./utils/utils";
-import { bindEventListener, findTargetDataset } from "./utils/dom";
+import { bindEventListener, getTargetVideoData } from "./utils/dom";
export default class YoutubeApp {
constructor() {
this.videoList = document.querySelector(".video-list");
+ this.savedVideoList = document.querySelector(".saved-video-list");
- this.#bindEvents();
- this.searchModalView = new SearchModalView();
this.videoStorage = new VideoStorage();
+ this.videoStorageView = new VideoStorageView();
+ this.searchModalView = new SearchModalView();
+
+ this.isWatchedVideoOnly = false;
+
+ this.#bindEvents();
+ this.#reloadStorageData();
}
#bindEvents() {
+ bindEventListener(
+ document.querySelector("#watch-later-video-button"),
+ "click",
+ this.#onClickWatchLaterVideoListButton
+ );
+ bindEventListener(
+ document.querySelector("#watched-video-button"),
+ "click",
+ this.#onClickWatchedVideoListButton
+ );
bindEventListener(
document.querySelector("#search-modal-button"),
"click",
@@ -31,6 +47,8 @@ export default class YoutubeApp {
"scroll",
throttle(this.#onScrollVideoList, DELAY_TIME)
);
+ bindEventListener(this.savedVideoList, "click", this.#onClickWatchedButton);
+ bindEventListener(this.savedVideoList, "click", this.#onClickDeleteButton);
bindEventListener(this.videoList, "click", this.#onClickSaveButton);
bindEventListener(
document.querySelector(".dimmer"),
@@ -39,21 +57,80 @@ export default class YoutubeApp {
);
}
+ #reloadStorageData = () => {
+ if (this.videoStorage.checkTypeVideoEmpty(this.isWatchedVideoOnly)) {
+ this.videoStorageView.renderEmptyStorage();
+ return;
+ }
+
+ this.videoStorageView.renderSavedVideo(
+ this.videoStorage.getVideos(),
+ this.isWatchedVideoOnly
+ );
+ };
+
+ #onClickWatchLaterVideoListButton = () => {
+ if (!this.isWatchedVideoOnly) {
+ return;
+ }
+
+ this.isWatchedVideoOnly = false;
+ this.videoStorageView.renderNavButtonStateChanged(this.isWatchedVideoOnly);
+
+ this.#reloadStorageData();
+ };
+
+ #onClickWatchedVideoListButton = () => {
+ if (this.isWatchedVideoOnly) {
+ return;
+ }
+
+ this.isWatchedVideoOnly = true;
+ this.videoStorageView.renderNavButtonStateChanged(this.isWatchedVideoOnly);
+
+ this.#reloadStorageData();
+ };
+
#onClickSearchModalButton = () => {
this.searchModalView.openSearchModal();
};
#onClickDimmer = () => {
this.searchModalView.closeSearchModal();
+ this.#reloadStorageData();
+ };
+
+ #onClickWatchedButton = ({ target }) => {
+ if (!target.matches(".video-item__watched-button")) return;
+
+ const videoData = getTargetVideoData(target, ".video-item");
+ this.videoStorage.setVideoStateWatched(videoData.videoId);
+
+ this.#reloadStorageData();
+ };
+
+ #onClickDeleteButton = ({ target }) => {
+ if (!target.matches(".video-item__delete-button")) {
+ return;
+ }
+
+ if (!window.confirm("정말로 삭제하시겠습니까?")) {
+ return;
+ }
+
+ const videoData = getTargetVideoData(target, ".video-item");
+ this.videoStorage.deleteVideo(videoData.videoId);
+
+ this.#reloadStorageData();
};
#onClickSaveButton = ({ target }) => {
if (!target.matches(".video-item__save-button")) return;
- const { videoId } = findTargetDataset(target, ".video-item");
+ const videoData = getTargetVideoData(target, ".video-item");
try {
- this.videoStorage.addVideoData(videoId);
+ this.videoStorage.addVideoData(videoData);
} catch ({ message }) {
alert(message);
}
@@ -88,42 +165,51 @@ export default class YoutubeApp {
this.searchModalView.renderSkeleton();
this.keyword = keyword;
- /**
- * 목 데이터로 검색 결과 대체
- */
- // const responseData = {
- // items: mockObject(),
- // nextPageToken: "ABCDEF",
- // };
-
- const responseData = await getSearchResult(this.keyword);
- this.nextPageToken = responseData.nextPageToken;
-
- // 검색 결과가 없을 경우
- if (responseData.items.length === 0) {
- this.searchModalView.renderNoResultPage();
- return;
- }
- this.searchModalView.renderSearchResult(responseData, this.videoStorage);
+ try {
+ const responseData = await getSearchResult(this.keyword);
+
+ if (responseData === null) {
+ this.searchModalView.unrenderSkeleton();
+ return;
+ }
+
+ this.nextPageToken = responseData.nextPageToken;
+ if (responseData.items.length === 0) {
+ this.searchModalView.renderNoResultPage();
+ return;
+ }
+
+ this.searchModalView.renderSearchResult(
+ responseData,
+ this.videoStorage.getVideoIdArray()
+ );
+ } catch {
+ this.searchModalView.unrenderSkeleton();
+ }
}
async searchNextPage() {
this.searchModalView.renderSkeleton();
- /**
- * 목 데이터로 검색 결과 대체
- */
- // const responseData = {
- // items: mockObject(),
- // nextPageToken: "ABCDEF",
- // };
- const responseData = await getSearchResult(
- this.keyword,
- this.nextPageToken
- );
-
- this.nextPageToken = responseData.nextPageToken;
- this.searchModalView.renderSearchResult(responseData, this.videoStorage);
+ try {
+ const responseData = await getSearchResult(
+ this.keyword,
+ this.nextPageToken
+ );
+
+ if (responseData === null) {
+ this.searchModalView.unrenderSkeleton();
+ return;
+ }
+
+ this.nextPageToken = responseData.nextPageToken;
+ this.searchModalView.renderSearchResult(
+ responseData,
+ this.videoStorage.getVideoIdArray()
+ );
+ } catch {
+ this.searchModalView.unrenderSkeleton();
+ }
}
}
diff --git a/src/js/__tests__/app.test.js b/src/js/__tests__/app.test.js
deleted file mode 100644
index e4bef0b98..000000000
--- a/src/js/__tests__/app.test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-// import getSearchResult from "../api/getSearchResult";
-// 테스트 케이스 오류를 막기 위해 작성해놓은 코드 입니다.
-test("", () => {});
-
-/**
- * 실제 API를 호출하지 않고 Mock Data를 이용하여 테스트하는 케이스는 불필요하다고 생각되는데...
- * 리뷰어님 생각은 어떠신지 궁금하네용 😥
- * 실제 API 통신을 테스트 할 때는 어떻게 하는지 알 수 있을까요?
- */
-
-// test("최초 검색 결과는 10개까지만 보여준다.", async () => {
-// fetch.mockResponseOnce(
-// JSON.stringify([
-// {
-// id: {
-// videoId: 1,
-// },
-// snippet: {
-// channelTitle: "essential;",
-// thumbnails: {
-// high: {
-// url: "https://i.ytimg.com/vi/ECfuKi5-Cfs/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDvmIcX-TgdcH2g_Bd4AUxw6hjmvQ",
-// },
-// },
-// publishTime: "2022-03-02T11:39:31Z",
-// title: "[Playlist] 너무 좋은데 괜찮으시겠어요?",
-// },
-// },
-// ])
-// );
-// const result = await getSearchResult("xooos");
-// expect(result.length).toBe(1);
-// });
diff --git a/src/js/__tests__/videoStorage.test.js b/src/js/__tests__/videoStorage.test.js
index 3b23941a0..933480624 100644
--- a/src/js/__tests__/videoStorage.test.js
+++ b/src/js/__tests__/videoStorage.test.js
@@ -8,7 +8,7 @@ describe("VideoStorage에 동영상의 데이터가 적절히 저장되어야
const testData = { id: 123, title: "테스트" };
videoStorage.addVideoData(testData);
- expect(videoStorage.getStorage().includes(testData)).toBe(true);
+ expect(videoStorage.getVideos().includes(testData)).toBe(true);
});
test("VideoStorage에 101개 이상의 데이터가 저장되면 에러 메시지를 반환한다.", () => {
@@ -23,25 +23,3 @@ describe("VideoStorage에 동영상의 데이터가 적절히 저장되어야
).toThrowError(ERROR_MESSAGE.VIDEO_STORAGE_OVERFLOW);
});
});
-
-describe("이미 저장된 videoId는 다시 저장될 수 없다.", () => {
- const responseId = "kkojaeId";
-
- beforeEach(() => {
- localStorage.clear();
- });
-
- test("이미 저장된 videoId이면 true를 반환한다.", () => {
- const videoStorage = new VideoStorage();
- videoStorage.addVideoData("kkojaeId");
-
- expect(videoStorage.isSavedVideoId(responseId)).toBe(true);
- });
-
- test("저장된 videoId가 아니면 false를 반환한다.", () => {
- const videoStorage = new VideoStorage();
- videoStorage.addVideoData("usageId");
-
- expect(videoStorage.isSavedVideoId(responseId)).toBe(false);
- });
-});
diff --git a/src/js/api/getSearchResult.js b/src/js/api/getSearchResult.js
index 941b56915..a5c2acebb 100644
--- a/src/js/api/getSearchResult.js
+++ b/src/js/api/getSearchResult.js
@@ -1,12 +1,26 @@
-import { ITEMS_PER_REQUEST } from "../constants/constants";
+import { ITEMS_PER_REQUEST, DEVELOP_MODE } from "../constants/constants";
+import mockObject from "../mockObject";
+
+let isProgressing = false;
export default async function getSearchResult(
searchKeyword,
nextPageToken = ""
) {
- const usageRedirect = "https://unruffled-turing-aacdf7.netlify.app";
- const kkojaeRedirect = "https://clever-aryabhata-ff1fc1.netlify.app";
- const REDIRECT_SERVER_HOST = usageRedirect;
+ if (isProgressing) {
+ return null;
+ }
+
+ if (DEVELOP_MODE) {
+ return {
+ items: mockObject(),
+ nextPageToken: "ABCDEF",
+ };
+ }
+
+ isProgressing = true;
+
+ const REDIRECT_SERVER_HOST = "https://unruffled-turing-aacdf7.netlify.app";
const url = new URL("youtube/v3/search", REDIRECT_SERVER_HOST);
const parameters = new URLSearchParams({
@@ -29,8 +43,18 @@ export default async function getSearchResult(
throw new Error(data.error.message);
}
+ isProgressing = false;
return data;
} catch (error) {
- console.error(error);
+ switch (error.message) {
+ case "Failed to fetch":
+ alert("인터넷 연결이 원활하지 않습니다. 잠시 후 다시 시도해주세요.");
+ break;
+ case "":
+ break;
+ }
+
+ isProgressing = false;
+ return null;
}
}
diff --git a/src/js/constants/constants.js b/src/js/constants/constants.js
index 43f899d66..8e33d5412 100644
--- a/src/js/constants/constants.js
+++ b/src/js/constants/constants.js
@@ -9,3 +9,5 @@ export const DELAY_TIME = 300;
export const ITEMS_PER_REQUEST = 10;
export const ALLOCATE_FOR_RENDER_PX = 40;
export const STORAGE_MAX_COUNT = 100;
+
+export const DEVELOP_MODE = true;
diff --git a/src/js/templates.js b/src/js/templates.js
deleted file mode 100644
index f35ff2651..000000000
--- a/src/js/templates.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { parsedDate } from "./utils/utils";
-import { ITEMS_PER_REQUEST } from "./constants/constants";
-
-const generateTemplate = {
- skeleton() {
- return `
-
-
-
-
-
- `.repeat(ITEMS_PER_REQUEST);
- },
- noResult() {
- return `
-
-
-
- 검색 결과가 없습니다
- 다른 키워드로 검색해보세요
-
-
- `;
- },
- videoItem({ id, channel, defaultThumbnail, title, date }, storage) {
- return `
-
-
- ${title}
-
- ${channel}
- ${date}
-
- ⬇ 저장
-
- `;
- },
- videoItems(responseData, videoStorage) {
- return responseData
- .map((item) =>
- this.videoItem(
- {
- id: item.id.videoId,
- channel: item.snippet.channelTitle,
- defaultThumbnail: item.snippet.thumbnails.high.url,
- title: item.snippet.title,
- date: parsedDate(item.snippet.publishTime),
- },
- videoStorage.getStorage()
- )
- )
- .join("");
- },
-};
-
-export default generateTemplate;
diff --git a/src/js/utils/dom.js b/src/js/utils/dom.js
index 943c51eeb..547785d64 100644
--- a/src/js/utils/dom.js
+++ b/src/js/utils/dom.js
@@ -4,8 +4,19 @@ export const bindEventListener = (element, type, callback) => {
element.addEventListener(type, callback);
};
-export const findTargetDataset = (target, parentSelector) => {
- return target.closest(parentSelector).dataset;
+export const getTargetVideoData = (target, parentSelector) => {
+ const parentElement = target.closest(parentSelector);
+ const videoDataObject = {
+ videoId: parentElement.dataset.videoId,
+ channel: parentElement.querySelector(".video-item__channel-name").innerText,
+ thumbnail: parentElement.querySelector(".video-item__thumbnail").src,
+ title: parentElement.querySelector(".video-item__title").innerText,
+ publishTime: parentElement.querySelector(".video-item__published-date")
+ .innerText,
+ isWatched: false,
+ };
+
+ return videoDataObject;
};
export const scrollToTop = (element = document.querySelector("body")) => {
diff --git a/src/js/view/SearchModalView.js b/src/js/view/SearchModalView.js
index 3cb57edb3..d5380f536 100644
--- a/src/js/view/SearchModalView.js
+++ b/src/js/view/SearchModalView.js
@@ -1,5 +1,6 @@
import { scrollToTop } from "../utils/dom";
-import generateTemplate from "../templates";
+import { parsedDate } from "../utils/utils";
+import { ITEMS_PER_REQUEST } from "../constants/constants";
import notFountImage from "../../assets/images/not_found.png";
export default class SearchModalView {
@@ -7,7 +8,11 @@ export default class SearchModalView {
this.modalContainer = document.querySelector(".modal-container");
this.searchInputKeyword = document.querySelector("#search-input-keyword");
this.searchResult = document.querySelector(".search-result");
- this.videoList = document.querySelector(".video-list");
+ this.videoList = document.querySelector(".search-result .video-list");
+ this.noResultDiv = document.querySelector(".search-result .no-result");
+ this.noResultImage = document.querySelector(
+ "#search-modal-no-result__image"
+ );
}
openSearchModal() {
@@ -17,17 +22,21 @@ export default class SearchModalView {
closeSearchModal() {
this.searchInputKeyword.value = "";
scrollToTop(this.videoList);
- this.videoList.innerHTML = "";
+ document
+ .querySelectorAll(".search-result .video-item")
+ .forEach((element) => element.remove());
this.modalContainer.classList.add("hide");
}
clearVideoList() {
scrollToTop(this.videoList);
- this.videoList.innerHTML = "";
+ document
+ .querySelectorAll(".search-result .video-item")
+ .forEach((element) => element.remove());
}
renderSkeleton() {
- this.videoList.insertAdjacentHTML("beforeend", generateTemplate.skeleton());
+ this.videoList.insertAdjacentHTML("beforeend", this.#skeleton());
}
unrenderSkeleton() {
@@ -39,19 +48,16 @@ export default class SearchModalView {
renderNoResultPage() {
this.searchResult.removeChild(this.videoList);
this.searchResult.classList.add("search-result--no-result");
- this.searchResult.insertAdjacentHTML(
- "beforeend",
- generateTemplate.noResult()
- );
-
- document.querySelector(".no-result__image").src = notFountImage;
+ this.noResultDiv.classList.remove("hide");
+ this.noResultImage.src = notFountImage;
}
- renderSearchResult(responseData, videoStorage) {
+ renderSearchResult(responseData, videoIdArray) {
this.unrenderSkeleton();
- const videoItemTemplate = generateTemplate.videoItems(
+ this.noResultDiv.classList.add("hide");
+ const videoItemTemplate = this.#videoItems(
responseData.items,
- videoStorage
+ videoIdArray
);
this.videoList.insertAdjacentHTML("beforeend", videoItemTemplate);
@@ -60,4 +66,68 @@ export default class SearchModalView {
hideSaveButton(target) {
target.classList.add("hide");
}
+
+ videoItem = ({ id, channel, thumbnail, title, date }, videoIdArray) => {
+ return `
+
+
+
+ ${title}
+
+ ${channel}
+ ${date}
+
+
+ ⬇ 저장
+
+ `;
+ };
+
+ #videoItems = (responseData, videoIdArray) => {
+ return responseData
+ .map((item) =>
+ this.videoItem(
+ {
+ id: item.id.videoId,
+ channel: item.snippet.channelTitle,
+ thumbnail: item.snippet.thumbnails.high.url,
+ title: item.snippet.title,
+ date: parsedDate(item.snippet.publishTime),
+ },
+ videoIdArray
+ )
+ )
+ .join("");
+ };
+
+ #skeleton = () => {
+ return `
+
+
+
+
+
+ `.repeat(ITEMS_PER_REQUEST);
+ };
+
+ #noResult(src, message) {
+ return `
+
+
+
+ ${message}
+
+
+ `;
+ }
}
diff --git a/src/js/view/VideoStorageView.js b/src/js/view/VideoStorageView.js
new file mode 100644
index 000000000..322781789
--- /dev/null
+++ b/src/js/view/VideoStorageView.js
@@ -0,0 +1,109 @@
+import notFountImage from "../../assets/images/not_found.png";
+
+export default class VideoStorageView {
+ constructor() {
+ this.savedVideoSection = document.querySelector(".saved-video__section");
+ this.savedVideoList = document.querySelector(".saved-video-list");
+ this.noResultDiv = document.querySelector(
+ ".saved-video__section .no-result"
+ );
+ }
+
+ savedVideoItem = ({ id, channel, thumbnail, title, date, isWatched }) => {
+ return `
+
+
+ ${title}
+ ${channel}
+ ${date}
+
+
+ ✅
+ 🗑️
+
+ `;
+ };
+
+ #savedVideoItems = (videoStorage, watchedVideoOnly) => {
+ if (watchedVideoOnly) {
+ return videoStorage
+ .map((item) => {
+ return item.isWatched
+ ? this.savedVideoItem({
+ id: item.videoId,
+ channel: item.channel,
+ thumbnail: item.thumbnail,
+ title: item.title,
+ date: item.publishTime,
+ isWatched: item.isWatched,
+ })
+ : "";
+ })
+ .join("");
+ }
+
+ return videoStorage
+ .map((item) => {
+ return item.isWatched
+ ? ""
+ : this.savedVideoItem({
+ id: item.videoId,
+ channel: item.channel,
+ thumbnail: item.thumbnail,
+ title: item.title,
+ date: item.publishTime,
+ isWatched: item.isWatched,
+ });
+ })
+ .join("");
+ };
+
+ renderEmptyStorage = () => {
+ this.savedVideoList.classList.add("hide");
+ this.noResultDiv.classList.remove("hide");
+ document.querySelector(".no-result__image").src = notFountImage;
+ };
+
+ renderSavedVideo = (videoData, watchedVideoOnly) => {
+ const videoItemTemplate = this.#savedVideoItems(
+ videoData,
+ watchedVideoOnly
+ );
+
+ document
+ .querySelectorAll(".saved-video-list .video-item")
+ .forEach((element) => element.remove());
+
+ this.savedVideoList.classList.remove("hide");
+ this.savedVideoList.insertAdjacentHTML("beforeend", videoItemTemplate);
+
+ if (!this.noResultDiv) {
+ return;
+ }
+
+ this.noResultDiv.classList.add("hide");
+ };
+
+ renderNavButtonStateChanged = (watchedVideoOnly) => {
+ if (watchedVideoOnly) {
+ document
+ .querySelector("#watch-later-video-button")
+ .classList.remove("selected");
+ document.querySelector("#watched-video-button").classList.add("selected");
+ return;
+ }
+
+ document
+ .querySelector("#watch-later-video-button")
+ .classList.add("selected");
+ document
+ .querySelector("#watched-video-button")
+ .classList.remove("selected");
+ };
+}