diff --git a/mission002/eastjun/css/style.css b/mission002/eastjun/css/style.css
new file mode 100644
index 0000000..d0185ba
--- /dev/null
+++ b/mission002/eastjun/css/style.css
@@ -0,0 +1,343 @@
+html,
+body {
+ margin: 0;
+ padding: 10px;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #4d4d4d;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+:focus {
+ outline: 0;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -125px;
+ width: 100%;
+ font-size: 60px;
+ text-align: center;
+ color: dimgray;
+ font-weight: 100;
+ font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
+}
+
+.todoapp .offline {
+ position: absolute;
+ top: -45px;
+ width: 100%;
+ text-align: center;
+ color: red;
+
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ border: 0;
+ color: inherit;
+ padding: 6px;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none;
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ width: 60px;
+ height: 34px;
+ font-size: 0;
+ position: absolute;
+ top: -52px;
+ left: -13px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ font-size: 22px;
+ color: #e6e6e6;
+ padding: 10px 27px 10px 27px;
+}
+
+.toggle-all:checked + label:before {
+ color: #737373;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
+}
+
+.todo-list li label {
+ word-break: break-all;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+}
+
+.todo-list li.completed label {
+ color: #d9d9d9;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #cc9a9a;
+ margin-bottom: 11px;
+ transition: color 0.2s ease-out;
+ cursor: pointer;
+}
+
+.todo-list li .destroy:hover {
+ color: #af5b5e;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.count-container {
+ color: #777;
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ border-top: 1px solid #e6e6e6;
+}
+
+.count-container:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+}
+
+.filters li a.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+}
+
+.clear-completed, html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 20px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #bfbfbf;
+ font-size: 10px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
diff --git a/mission002/eastjun/index.html b/mission002/eastjun/index.html
new file mode 100644
index 0000000..8590543
--- /dev/null
+++ b/mission002/eastjun/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ 이벤트 - TODOS
+
+
+
+
+
+
TODOS
+
+
오프라인 상태에서는 데이터를 변경할 수 없습니다.
+
+
+
+
+
+
+
+
+
diff --git a/mission002/eastjun/js/App.js b/mission002/eastjun/js/App.js
new file mode 100644
index 0000000..b1b86f0
--- /dev/null
+++ b/mission002/eastjun/js/App.js
@@ -0,0 +1,99 @@
+import TodoList from './components/TodoList.js'
+import TodoInput from './components/TodoInput.js'
+import TodoCount from './components/TodoCount.js'
+import TodoStatus from './components/TodoStatus.js'
+import { todoItemStatusMap } from './utils/utils.js'
+import storage from './store/localStorage.js'
+import api from './api/api.js'
+
+function TodoApp () {
+ this.todoItems = []
+ this.isOnline = navigator.onLine
+
+ const initNetworkEventListener = () => {
+ window.addEventListener('offline', () => setIsOnline())
+ window.addEventListener('online', () => setIsOnline())
+ }
+
+ initNetworkEventListener()
+
+ const setIsOnline = () => {
+ this.isOnline = navigator.onLine
+ const offlineAlertClassList = document.querySelector('.alert-container .offline').classList
+ this.isOnline ? offlineAlertClassList.add('hidden') : offlineAlertClassList.remove('hidden')
+ }
+
+ this.render = (items) => {
+ todoList.render(items)
+ todoCount.render(items)
+ }
+
+ this.setState = (updatedItems) => {
+ this.todoItems = updatedItems
+ storage.set(this.todoItems)
+ this.render(this.todoItems)
+ }
+
+ this.initOfflineTodoList = () => {
+ const $offlineAlert = document.querySelector('.alert-container .offline')
+ $offlineAlert.classList.remove('hidden')
+ this.todoItems = storage.get()
+ if (this.todoItems) {
+ this.render(this.todoItems)
+ }
+ }
+
+ new TodoInput({
+ setState: (todoItems) => {
+ this.setState(todoItems)
+ }
+ })
+
+ new TodoStatus({
+ filter: (status) => {
+ switch (status) {
+ case todoItemStatusMap.ALL: {
+ this.render(this.todoItems)
+ break
+ }
+ case todoItemStatusMap.ACTIVE: {
+ const filteredItems = this.todoItems.filter(item => item.isCompleted === false)
+ this.render(filteredItems)
+ break
+ }
+ case todoItemStatusMap.COMPLETED: {
+ const filteredItems = this.todoItems.filter(item => item.isCompleted === true)
+ this.render(filteredItems)
+ break
+ }
+ }
+ }
+ })
+
+ const todoList = new TodoList({
+ loadTodoItems: async () => {
+ try {
+ const todoItems = await api.todoItem.get()
+ this.setState(todoItems)
+ } catch (e) {
+ throw new Error(e)
+ }
+ },
+ setState: (todoItems) => {
+ this.setState(todoItems)
+ },
+ toggleItem: (index) => {
+ this.todoItems[index].isCompleted = !this.todoItems[index].isCompleted
+ }
+ })
+
+ const todoCount = new TodoCount({
+ todoItems: this.todoItems
+ })
+
+ if (!this.isOnline) {
+ this.initOfflineTodoList()
+ }
+}
+
+new TodoApp()
diff --git a/mission002/eastjun/js/api/api.js b/mission002/eastjun/js/api/api.js
new file mode 100644
index 0000000..ee8046c
--- /dev/null
+++ b/mission002/eastjun/js/api/api.js
@@ -0,0 +1,44 @@
+const DOMAIN = 'http://todo-api.roto.codes/'
+
+const USERNAME = 'eastjun'
+
+const METHOD = {
+ PUT() {
+ return {
+ method: 'PUT',
+ }
+ },
+ DELETE() {
+ return {
+ method: 'DELETE',
+ }
+ },
+ POST(data) {
+ return {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ content: data,
+ }),
+ }
+ },
+}
+
+const api = (() => {
+ const request = (uri, config) => fetch(uri, config).then((response) => response.json())
+
+ const todoItem = {
+ get: () => request(`${DOMAIN}${USERNAME}`),
+ add: (todoItem) => request(`${DOMAIN}${USERNAME}`, METHOD.POST(todoItem.content)),
+ complete: (id) => request(`${DOMAIN}${USERNAME}/${id}/toggle`, METHOD.PUT()),
+ remove: (id) => request(`${DOMAIN}${USERNAME}/${id}`, METHOD.DELETE()),
+ }
+
+ return {
+ todoItem
+ }
+})()
+
+export default api
diff --git a/mission002/eastjun/js/components/TodoCount.js b/mission002/eastjun/js/components/TodoCount.js
new file mode 100644
index 0000000..bdfc70b
--- /dev/null
+++ b/mission002/eastjun/js/components/TodoCount.js
@@ -0,0 +1,7 @@
+export default function TodoCount({ todoItems }) {
+ this.render = (todoItems) => {
+ document.querySelector('#total-count .count').innerHTML = todoItems ? todoItems.length : 0
+ }
+
+ this.render(todoItems)
+}
diff --git a/mission002/eastjun/js/components/TodoInput.js b/mission002/eastjun/js/components/TodoInput.js
new file mode 100644
index 0000000..2f33b9f
--- /dev/null
+++ b/mission002/eastjun/js/components/TodoInput.js
@@ -0,0 +1,46 @@
+import validator from '../utils/validator.js'
+import api from '../api/api.js'
+import TodoItem from './TodoItem.js'
+
+export default function TodoInput({ setState }) {
+ const $todoInput = document.querySelector('#new-todo-title')
+
+ const initEventListener = () => {
+ $todoInput.addEventListener('keydown', (event) => this.addTodoItem(event))
+ }
+
+ initEventListener()
+
+ this.addTodoItem = async (event) => {
+ const $newTodoTarget = event.target
+
+ if (!this.isValid(event, $newTodoTarget.value)) {
+ return
+ }
+
+ this.onAdd($newTodoTarget)
+ }
+
+ this.onAdd = async ($newTodoTarget) => {
+ if (!navigator.onLine) {
+ return
+ }
+
+ try {
+ await api.todoItem.add(new TodoItem($newTodoTarget.value))
+ this.initValue($newTodoTarget)
+ const updateTodoItems = await api.todoItem.get()
+ setState(updateTodoItems)
+ } catch (e) {
+ throw new Error(e)
+ }
+ }
+
+ this.isValid = (event, newTodoContents) => {
+ return validator.isEnterKey(event.key) && !validator.isEmptyString(newTodoContents)
+ }
+
+ this.initValue = ($newTodoTarget) => {
+ $newTodoTarget.value = ''
+ }
+}
diff --git a/mission002/eastjun/js/components/TodoItem.js b/mission002/eastjun/js/components/TodoItem.js
new file mode 100644
index 0000000..963c843
--- /dev/null
+++ b/mission002/eastjun/js/components/TodoItem.js
@@ -0,0 +1,4 @@
+export default function TodoItem(contents) {
+ this.content = contents
+ this.isCompleted = false
+}
diff --git a/mission002/eastjun/js/components/TodoList.js b/mission002/eastjun/js/components/TodoList.js
new file mode 100644
index 0000000..9083480
--- /dev/null
+++ b/mission002/eastjun/js/components/TodoList.js
@@ -0,0 +1,110 @@
+import { todoItemTemplate } from '../utils/templates.js'
+import validator from '../utils/validator.js'
+import api from '../api/api.js'
+
+export default function TodoList({ loadTodoItems, setState, toggleItem }) {
+ this.$todoList = document.querySelector('#todo-list')
+
+ const initEventListener = () => {
+ this.$todoList.addEventListener('click', (event) => {
+ const classList = event.target.classList
+ if (classList.contains('toggle')) onToggleItem(event)
+ if (classList.contains('destroy')) onRemoveItem(event)
+ })
+
+ this.$todoList.addEventListener('dblclick', (event) => {
+ const classList = event.target.classList
+ if (classList.contains('label')) onFocusItem(event)
+ })
+
+ this.$todoList.addEventListener('keydown', (event) => {
+ const classList = event.target.classList
+ if (classList.contains('edit')) onEdit(event)
+ })
+ }
+
+ const onToggleItem = async (event) => {
+ if (!navigator.onLine) {
+ return
+ }
+
+ const $targetTodoItem = event.target.closest('li')
+ $targetTodoItem.classList.toggle('completed')
+
+ if (!navigator.onLine) {
+ toggleItem(getIndex(event))
+ return
+ }
+
+ try {
+ const itemId = $targetTodoItem.dataset.id
+ await api.todoItem.complete(itemId)
+ toggleItem(getIndex(event))
+ } catch (e) {
+ throw new Error(e)
+ }
+ }
+
+ const onRemoveItem = async (event) => {
+ if (!navigator.onLine) {
+ return
+ }
+
+ const $targetTodoItem = event.target.closest('li')
+ const itemId = $targetTodoItem.dataset.id
+ try {
+ await api.todoItem.remove(itemId)
+ const todoItems = await api.todoItem.get()
+ setState(todoItems)
+ } catch (e) {
+ throw new Error(e)
+ }
+ }
+
+ this.init = () => {
+ if (navigator.onLine) {
+ loadTodoItems()
+ }
+ initEventListener()
+ }
+
+ this.init()
+
+ this.render = (items) => {
+ const template = items.map(todoItemTemplate)
+ this.$todoList.innerHTML = template.join('')
+ }
+
+ const getIndex = (event) => {
+ return event.target.closest('li').dataset.index
+ }
+
+ const onFocusItem = (event) => {
+ const $target = event.target.closest('li')
+ $target.classList.toggle('editing')
+ }
+
+ const onEdit = (event) => {
+ if (!navigator.onLine) {
+ return
+ }
+
+ const $target = event.target.closest('li')
+ const editValue = $target.querySelector('input.edit').value
+
+ if (validator.isEnterKey(event.key)) {
+ return isValidInputValue($target, editValue)
+ }
+
+ if (validator.isEscKey(event.key)) {
+ return $target.classList.toggle('editing')
+ }
+ }
+
+ const isValidInputValue = ($target, inputValue) => {
+ if (validator.isString(inputValue) && !validator.isEmptyString(inputValue)){
+ $target.querySelector('label').innerText = inputValue
+ $target.classList.toggle('editing')
+ }
+ }
+}
diff --git a/mission002/eastjun/js/components/TodoStatus.js b/mission002/eastjun/js/components/TodoStatus.js
new file mode 100644
index 0000000..5c2ecdb
--- /dev/null
+++ b/mission002/eastjun/js/components/TodoStatus.js
@@ -0,0 +1,24 @@
+export default function TodoStatus({ todoItems, filter }) {
+ this.$todoStatus = document.querySelector('#todo-status-tabs')
+
+ const initEventListener = () => {
+ this.$todoStatus.addEventListener('click', (event) => {
+ const $target = event.target
+ filter($target.className)
+ toggleTab($target)
+ })
+ }
+
+ initEventListener()
+
+ const toggleTab = ($target) => {
+ const $tabs = document.querySelectorAll('#todo-status-tabs li a')
+ $tabs.forEach(status => {
+ if (status.classList.contains('selected')) {
+ status.classList.remove('selected')
+ }
+ })
+ $target.classList.add('selected')
+ }
+}
+
diff --git a/mission002/eastjun/js/store/localStorage.js b/mission002/eastjun/js/store/localStorage.js
new file mode 100644
index 0000000..1a87929
--- /dev/null
+++ b/mission002/eastjun/js/store/localStorage.js
@@ -0,0 +1,16 @@
+const TODO_ITEM_KEY = 'todoItems'
+
+const storage = (() => {
+ const set = (todoItems) => {
+ localStorage.setItem(TODO_ITEM_KEY, JSON.stringify(todoItems))
+ }
+
+ const get = () => JSON.parse(localStorage.getItem(TODO_ITEM_KEY))
+
+ return {
+ get,
+ set,
+ }
+})()
+
+export default storage
diff --git a/mission002/eastjun/js/utils/templates.js b/mission002/eastjun/js/utils/templates.js
new file mode 100644
index 0000000..4362d4c
--- /dev/null
+++ b/mission002/eastjun/js/utils/templates.js
@@ -0,0 +1,13 @@
+const todoItemTemplate = (todoItem, index) => `
+
+
+
+
+
+
+
+ `
+
+export {
+ todoItemTemplate,
+}
diff --git a/mission002/eastjun/js/utils/utils.js b/mission002/eastjun/js/utils/utils.js
new file mode 100644
index 0000000..d97eab5
--- /dev/null
+++ b/mission002/eastjun/js/utils/utils.js
@@ -0,0 +1,12 @@
+const todoItemStatusMap = {
+ ALL: 'all',
+ ACTIVE: 'active',
+ COMPLETED: 'completed',
+}
+
+const keyboardMap = {
+ ENTER: 'Enter',
+ ESC: 'Escape',
+}
+
+export { todoItemStatusMap, keyboardMap }
diff --git a/mission002/eastjun/js/utils/validator.js b/mission002/eastjun/js/utils/validator.js
new file mode 100644
index 0000000..1d5ece0
--- /dev/null
+++ b/mission002/eastjun/js/utils/validator.js
@@ -0,0 +1,18 @@
+import { keyboardMap } from './utils.js'
+
+const validator = {
+ isString(str) {
+ return typeof str === 'string' || str instanceof String
+ },
+ isEmptyString(str) {
+ return (!str || 0 === str.length)
+ },
+ isEnterKey(key) {
+ return key === keyboardMap.ENTER
+ },
+ isEscKey(key) {
+ return key === keyboardMap.ESC
+ },
+}
+
+export default validator