├── 1-vanilla ├── index.html ├── js │ ├── app.js │ ├── controllers │ │ └── MainController.js │ ├── models │ │ ├── HistoryModel.js │ │ ├── KeywordModel.js │ │ └── SearchModel.js │ └── views │ │ ├── FormView.js │ │ ├── HistoryView.js │ │ ├── KeywordView.js │ │ ├── ResultView.js │ │ ├── TabView.js │ │ └── View.js └── style.css └── 2-vue ├── index.html ├── js ├── app.js └── models ├── models └── style.css /1-vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MVC with Vanilla.JS 8 | 9 | 10 | 11 |
12 |
13 |

검색

14 |
15 | 16 |
17 |
18 | 19 | 20 |
21 | 22 |
23 |
    24 |
  • 추천 검색어
  • 25 |
  • 최근 검색어
  • 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /1-vanilla/js/app.js: -------------------------------------------------------------------------------- 1 | import MainController from './controllers/MainController.js' 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | MainController.init() 5 | }) -------------------------------------------------------------------------------- /1-vanilla/js/controllers/MainController.js: -------------------------------------------------------------------------------- 1 | import FormView from '../views/FormView.js' 2 | import ResultView from '../views/ResultView.js' 3 | import TabView from '../views/TabView.js' 4 | import KeywordView from '../views/KeywordView.js' 5 | import HistoryView from '../views/HistoryView.js' 6 | 7 | import SearchModel from '../models/SearchModel.js' 8 | import KeywordModel from '../models/KeywordModel.js' 9 | import HistoryModel from '../models/HistoryModel.js' 10 | 11 | const tag = '[MainController]' 12 | 13 | export default { 14 | init() { 15 | FormView.setup(document.querySelector('form')) 16 | .on('@submit', e => this.onSubmit(e.detail.input)) 17 | .on('@reset', e => this.onResetForm()) 18 | 19 | TabView.setup(document.querySelector('#tabs')) 20 | .on('@change', e => this.onChangeTab(e.detail.tabName)) 21 | 22 | KeywordView.setup(document.querySelector('#search-keyword')) 23 | .on('@click', e => this.onClickKeyword(e.detail.keyword)) 24 | 25 | HistoryView.setup(document.querySelector('#search-history')) 26 | .on('@click', e => this.onClickHistory(e.detail.keyword)) 27 | .on('@remove', e => this.onRemoveHistory(e.detail.keyword)) 28 | 29 | ResultView.setup(document.querySelector('#search-result')) 30 | 31 | this.selectedTab = '추천 검색어' 32 | this.renderView() 33 | }, 34 | search(query) { 35 | FormView.setValue(query) 36 | SearchModel.list(query).then(data => { 37 | this.onSearchResult(data) 38 | }) 39 | }, 40 | renderView() { 41 | console.log(tag, 'rednerView()') 42 | TabView.setActiveTab(this.selectedTab) 43 | 44 | if (this.selectedTab === '추천 검색어') { 45 | this.fetchSearchKeyword() 46 | HistoryView.hide() 47 | } else { 48 | this.fetchSearchHistory() 49 | KeywordView.hide() 50 | } 51 | 52 | ResultView.hide() 53 | }, 54 | fetchSearchKeyword() { 55 | KeywordModel.list().then(data => { 56 | KeywordView.render(data) 57 | }) 58 | }, 59 | fetchSearchHistory() { 60 | HistoryModel.list().then(data => { 61 | const a = HistoryView.render(data) 62 | a.bindRemoveBtn() 63 | }) 64 | }, 65 | 66 | onSubmit(input) { 67 | console.log(tag, 'onSubmit()', input) 68 | this.search(input) 69 | }, 70 | onResetForm() { 71 | console.log(tag, 'onResetForm()') 72 | this.renderView() 73 | }, 74 | onSearchResult(data) { 75 | TabView.hide() 76 | KeywordView.hide() 77 | HistoryView.hide() 78 | ResultView.render(data) 79 | }, 80 | onChangeTab(tabName) { 81 | this.selectedTab = tabName 82 | this.renderView() 83 | }, 84 | onClickKeyword(keyword) { 85 | this.search(keyword) 86 | }, 87 | onClickHistory(keyword) { 88 | this.search(keyword) 89 | }, 90 | onRemoveHistory(keyword) { 91 | HistoryModel.remove(keyword) 92 | this.renderView() 93 | } 94 | } -------------------------------------------------------------------------------- /1-vanilla/js/models/HistoryModel.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: [ 3 | { keyword: '검색기록2', date: '12.03' }, 4 | { keyword: '검색기록1', date: '12.02'}, 5 | { keyword: '검색기록0', date: '12.01' }, 6 | ], 7 | 8 | list() { 9 | return Promise.resolve(this.data) 10 | }, 11 | 12 | add(keyword = '') { 13 | keyword = keyword.trim() 14 | if (!keyword) return 15 | if (this.data.some(item => item.keyword === keyword)) { 16 | this.remove(keyword) 17 | } 18 | 19 | const date = '12.31' 20 | this.data = [{keyword, date}, ...this.data] 21 | }, 22 | 23 | remove(keyword) { 24 | this.data = this.data.filter(item => item.keyword !== keyword) 25 | } 26 | } -------------------------------------------------------------------------------- /1-vanilla/js/models/KeywordModel.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: [ 3 | {keyword: '이탈리아'}, 4 | {keyword: '세프의요리'}, 5 | {keyword: '제철'}, 6 | {keyword: '홈파티'} 7 | ], 8 | 9 | list() { 10 | return new Promise(res => { 11 | setTimeout(() => { 12 | res(this.data) 13 | }, 200) 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /1-vanilla/js/models/SearchModel.js: -------------------------------------------------------------------------------- 1 | const data = [ 2 | { 3 | id: 1, 4 | name: "비건 샐러드", 5 | image: 6 | "https://images.unsplash.com/photo-1512621776951-a57141f2eefd?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=200&q=80", 7 | }, 8 | { 9 | id: 2, 10 | name: "수제 햄버거", 11 | image: 12 | "https://images.unsplash.com/photo-1550317138-10000687a72b?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=200&q=80", 13 | }, 14 | ]; 15 | 16 | export default { 17 | list(query) { 18 | return new Promise(res => { 19 | setTimeout(()=> { 20 | res(data) 21 | }, 200); 22 | }) 23 | } 24 | } -------------------------------------------------------------------------------- /1-vanilla/js/views/FormView.js: -------------------------------------------------------------------------------- 1 | import View from './View.js' 2 | 3 | const tag = '[FormView]' 4 | 5 | const FormView = Object.create(View) 6 | 7 | FormView.setup = function (el) { 8 | this.init(el) 9 | this.inputEl = el.querySelector('[type=text]') 10 | this.resetEl = el.querySelector('[type=reset') 11 | this.showResetBtn(false) 12 | this.bindEvents() 13 | return this 14 | } 15 | 16 | FormView.showResetBtn = function (show = true) { 17 | this.resetEl.style.display = show ? 'block' : 'none' 18 | } 19 | 20 | FormView.bindEvents = function() { 21 | this.on('submit', e => e.preventDefault()) 22 | this.inputEl.addEventListener('keyup', e => this.onKeyup(e)) 23 | this.resetEl.addEventListener('click', e => this.onClickReset()) 24 | } 25 | 26 | FormView.onKeyup = function (e) { 27 | const enter = 13 28 | this.showResetBtn(this.inputEl.value.length) 29 | if (!this.inputEl.value.length) this.emit('@reset') 30 | if (e.keyCode !== enter) return 31 | this.emit('@submit', {input: this.inputEl.value}) 32 | } 33 | 34 | FormView.onClickReset = function() { 35 | this.emit('@reset') 36 | this.showResetBtn(false) 37 | } 38 | 39 | FormView.setValue = function (value = '') { 40 | this.inputEl.value = value 41 | this.showResetBtn(this.inputEl.value.length) 42 | } 43 | 44 | export default FormView -------------------------------------------------------------------------------- /1-vanilla/js/views/HistoryView.js: -------------------------------------------------------------------------------- 1 | import KeywordView from './KeywordView.js' 2 | 3 | const tag = '[HistoryView]' 4 | 5 | const HistoryView = Object.create(KeywordView) 6 | 7 | HistoryView.messages.NO_KEYWORDS = '검색 이력이 없습니다' 8 | 9 | HistoryView.getKeywordsHtml = function (data) { 10 | return data.reduce((html, item) => { 11 | html += `
  • 12 | ${item.keyword} 13 | ${item.date} 14 | 15 |
  • ` 16 | return html 17 | }, '" 18 | } 19 | 20 | HistoryView.bindRemoveBtn = function () { 21 | Array.from(this.el.querySelectorAll('button.btn-remove')).forEach(btn => { 22 | btn.addEventListener('click', e => { 23 | e.stopPropagation() 24 | this.onRemove(btn.parentElement.dataset.keyword) 25 | }) 26 | }) 27 | } 28 | 29 | HistoryView.onRemove = function (keyword) { 30 | this.emit('@remove', {keyword}) 31 | } 32 | 33 | export default HistoryView -------------------------------------------------------------------------------- /1-vanilla/js/views/KeywordView.js: -------------------------------------------------------------------------------- 1 | import View from './View.js' 2 | 3 | const tag = '[KeywordView]' 4 | 5 | const KeywordView = Object.create(View) 6 | 7 | KeywordView.messages = { 8 | NO_KEYWORDS: '추천 검색어가 없습니다' 9 | } 10 | 11 | KeywordView.setup = function (el) { 12 | this.init(el) 13 | return this 14 | } 15 | 16 | KeywordView.render = function (data = []) { 17 | this.el.innerHTML = data.length ? this.getKeywordsHtml(data) : this.messages.NO_KEYWORDS 18 | this.show() 19 | this.bindClickEvent() 20 | return this 21 | } 22 | 23 | KeywordView.getKeywordsHtml = function (data) { 24 | return data.reduce((html, item, index) => { 25 | html += `
  • ${index + 1}${item.keyword}
  • ` 26 | return html 27 | }, '" 28 | } 29 | 30 | KeywordView.bindClickEvent = function() { 31 | Array.from(this.el.querySelectorAll('li')).forEach(li => { 32 | li.addEventListener('click', e => this.onClickKeyword(e)) 33 | }) 34 | } 35 | 36 | KeywordView.onClickKeyword = function (e) { 37 | const {keyword} = e.currentTarget.dataset 38 | this.emit('@click', {keyword}) 39 | } 40 | 41 | export default KeywordView -------------------------------------------------------------------------------- /1-vanilla/js/views/ResultView.js: -------------------------------------------------------------------------------- 1 | import View from './View.js' 2 | 3 | const tag = '[ResultView]' 4 | 5 | const ResultView = Object.create(View) 6 | 7 | ResultView.messages = { 8 | NO_RESULT: '검색 결과가 없습니다' 9 | } 10 | 11 | ResultView.setup = function (el) { 12 | this.init(el) 13 | } 14 | 15 | ResultView.render = function (data = []) { 16 | console.log(tag, 'render()', data) 17 | this.el.innerHTML = data.length ? this.getSearchResultsHtml(data) : this.messages.NO_RESULT 18 | this.show() 19 | } 20 | 21 | ResultView.getSearchResultsHtml = function (data) { 22 | return data.reduce((html, item) => { 23 | html += this.getSearchItemHtml(item) 24 | return html 25 | }, '' 26 | } 27 | 28 | ResultView.getSearchItemHtml = function (item) { 29 | return `
  • 30 | 31 |

    ${item.name}

    32 |
  • ` 33 | } 34 | 35 | export default ResultView -------------------------------------------------------------------------------- /1-vanilla/js/views/TabView.js: -------------------------------------------------------------------------------- 1 | import View from './View.js' 2 | 3 | const tag = '[TabView]' 4 | 5 | const TabView = Object.create(View) 6 | 7 | TabView.tabNames = { 8 | recommand: '추천 검색어', 9 | recent: '최근 검색어', 10 | } 11 | 12 | TabView.setup = function(el) { 13 | this.init(el) 14 | this.bindClick() 15 | return this 16 | } 17 | 18 | TabView.setActiveTab = function (tabName) { 19 | Array.from(this.el.children).forEach(li => { 20 | li.className = li.innerHTML === tabName ? 'active' : '' 21 | }) 22 | this.show() 23 | } 24 | 25 | TabView.bindClick = function() { 26 | Array.from(this.el.children).forEach(li => { 27 | li.addEventListener('click', e => this.onClick(li.innerHTML)) 28 | }) 29 | } 30 | 31 | TabView.onClick = function (tabName) { 32 | this.setActiveTab(tabName) 33 | this.emit('@change', {tabName}) 34 | } 35 | 36 | export default TabView 37 | -------------------------------------------------------------------------------- /1-vanilla/js/views/View.js: -------------------------------------------------------------------------------- 1 | const tag = '[View]' 2 | 3 | export default { 4 | init(el) { 5 | if (!el) throw el 6 | this.el = el 7 | return this 8 | }, 9 | 10 | on(event, handler) { 11 | this.el.addEventListener(event, handler) 12 | return this 13 | }, 14 | 15 | emit(event, data) { 16 | const evt = new CustomEvent(event, { detail: data }) 17 | this.el.dispatchEvent(evt) 18 | return this 19 | }, 20 | 21 | hide() { 22 | this.el.style.display = 'none' 23 | return this 24 | }, 25 | 26 | show() { 27 | this.el.style.display = '' 28 | return this 29 | } 30 | } -------------------------------------------------------------------------------- /1-vanilla/style.css: -------------------------------------------------------------------------------- 1 | body, ul { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | ul { 6 | list-style: none; 7 | } 8 | img { 9 | width: 100%; 10 | } 11 | 12 | .container { 13 | margin: 0 15px 0 15px; 14 | } 15 | header { 16 | border-bottom: 1px #ccc solid; 17 | padding: 15px 0 15px 0; 18 | text-align: center; 19 | } 20 | input[type=text] { 21 | display: block; 22 | box-sizing: border-box; 23 | width: 100%; 24 | margin: 15px 0 15px 0; 25 | padding: 10px 15px; 26 | font-size: 14px; 27 | line-height: 1.5; 28 | border: 1px solid #cccccc; 29 | 30 | } 31 | .content { 32 | border: 1px solid #ccc; 33 | } 34 | ul.tabs { 35 | display: flex; 36 | } 37 | .tabs li { 38 | display: inline-block; 39 | width: 50%; 40 | padding: 15px; 41 | text-align: center; 42 | box-sizing: border-box; 43 | border-bottom: 1px solid #ccc; 44 | background-color: #eee; 45 | color: #999; 46 | } 47 | .tabs li.active { 48 | background-color: #2ac1bc; 49 | color: #fff; 50 | } 51 | .list li { 52 | box-sizing: border-box; 53 | display: block; 54 | padding: 15px; 55 | border-bottom: 1px solid #ccc; 56 | position: relative; 57 | } 58 | .list li:last-child { 59 | border-bottom: none; 60 | } 61 | .list li .number{ 62 | margin-right: 15px; 63 | color: #ccc; 64 | } 65 | .list li .date{ 66 | position: absolute; 67 | right: 50px; 68 | top: 15px; 69 | margin-right: 15px; 70 | color: #ccc; 71 | } 72 | .list li .btn-remove{ 73 | position: absolute; 74 | right: 0px; 75 | top: 15px; 76 | margin-right: 15px; 77 | } 78 | 79 | form { 80 | position: relative; 81 | } 82 | .btn-reset, 83 | .btn-remove { 84 | border-radius: 50%; 85 | background-color: #ccc; 86 | color: white; 87 | border: none; 88 | padding: 2px 5px; 89 | } 90 | .btn-reset { 91 | position: absolute; 92 | top: 12px; 93 | right: 10px; 94 | } 95 | .btn-reset::before, 96 | .btn-remove::before { 97 | content: 'X' 98 | } 99 | 100 | #search-result li { 101 | display: flex; 102 | margin-bottom: 15px; 103 | } 104 | #search-result img { 105 | width: 30%; 106 | height: 30%; 107 | } -------------------------------------------------------------------------------- /2-vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MVC with Vanilla.JS 8 | 9 | 10 | 11 |
    12 |
    13 |

    검색

    14 |
    15 | 16 |
    17 |
    18 | 19 | 20 |
    21 | 22 |
    23 |
    24 |
    25 |
      26 |
    • 27 | {{item.name}} 28 |
    • 29 |
    30 |
    31 |
    32 | {{query}} 검색어로 찾을수 없습니다 33 |
    34 |
    35 |
    36 |
      37 |
    • 39 | {{tab}} 40 |
    • 41 |
    42 | 43 |
    44 |
    45 |
      46 |
    • 48 | {{index + 1}} {{item.keyword}} 49 |
    • 50 |
    51 |
    52 |
    53 | 추천 검색어가 없습니다 54 |
    55 |
    56 |
    57 |
    58 |
      59 |
    • 61 | {{item.keyword}} 62 | {{item.date}} 63 | 65 |
    • 66 |
    67 |
    68 |
    69 | 최근 검색어가 없습니다 70 |
    71 |
    72 |
    73 |
    74 |
    75 |
    76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /2-vue/js/app.js: -------------------------------------------------------------------------------- 1 | import SearchModel from './models/SearchModel.js' 2 | import KeywordModel from './models/KeywordModel.js' 3 | import HistoryModel from './models/HistoryModel.js' 4 | 5 | new Vue({ 6 | el: '#app', 7 | data: { 8 | query: '', 9 | submitted: false, 10 | tabs: ['추천 검색어', '최근 검색어'], 11 | selectedTab: '', 12 | keywords: [], 13 | history: [], 14 | searchResult: [] 15 | }, 16 | created() { 17 | this.selectedTab = this.tabs[0] 18 | this.fetchKeyword() 19 | this.fetchHistory() 20 | }, 21 | methods: { 22 | onSubmit(e) { 23 | this.search() 24 | }, 25 | onKeyup(e) { 26 | if (!this.query.length) this.resetForm() 27 | }, 28 | onReset(e) { 29 | this.resetForm() 30 | }, 31 | onClickTab(tab) { 32 | this.selectedTab = tab 33 | }, 34 | onClickKeyword(keyword) { 35 | this.query = keyword; 36 | this.search() 37 | }, 38 | onClickRemoveHistory(keyword) { 39 | HistoryModel.remove(keyword) 40 | this.fetchHistory() 41 | }, 42 | fetchKeyword() { 43 | KeywordModel.list().then(data => { 44 | this.keywords = data 45 | }) 46 | }, 47 | fetchHistory() { 48 | HistoryModel.list().then(data => { 49 | this.history = data 50 | }) 51 | }, 52 | search() { 53 | SearchModel.list().then(data => { 54 | this.submitted = true 55 | this.searchResult = data 56 | }) 57 | HistoryModel.add(this.query) 58 | this.fetchHistory() 59 | }, 60 | resetForm() { 61 | this.query = '' 62 | this.submitted = false 63 | this.searchResult = [] 64 | } 65 | } 66 | }) -------------------------------------------------------------------------------- /2-vue/js/models: -------------------------------------------------------------------------------- 1 | ../../1-vanilla/js/models -------------------------------------------------------------------------------- /2-vue/models: -------------------------------------------------------------------------------- 1 | ../1-vanilla/js/models -------------------------------------------------------------------------------- /2-vue/style.css: -------------------------------------------------------------------------------- 1 | ../1-vanilla/style.css --------------------------------------------------------------------------------