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 @@

👩🏻‍💻 나만의 유튜브 강의실 👨🏻‍💻

+
+ + +
+ no result image +

+ 저장된 영상이 없습니다
나만의 영상을 검색하여 저장해보세요 +

+
+
- + 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 ` -
    - no result image -

    - 검색 결과가 없습니다
    - 다른 키워드로 검색해보세요 -

    -
    - `; - }, - videoItem({ id, channel, defaultThumbnail, title, date }, storage) { - return `
  • - video-item-thumbnail -

    - ${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 `
  • + + video-item-thumbnail +

    + ${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 ` +
    + no result image +

    + ${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 `
  • + + video-item-thumbnail +

    ${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"); + }; +}