diff --git a/index.html b/index.html new file mode 100644 index 00000000..a6194b9f --- /dev/null +++ b/index.html @@ -0,0 +1,10 @@ + + + Notion Project + + +
+ + + + diff --git a/src/components/App.js b/src/components/App.js new file mode 100644 index 00000000..280329ee --- /dev/null +++ b/src/components/App.js @@ -0,0 +1,42 @@ +import PostEditPage from "./PostEditPage.js"; +import PostSidebar from "./PostSidebar.js"; +import { initRouter } from "../utils/router.js"; + +export default function App({ $target }) { + const $postSideBarContainer = document.createElement("div"); + const $postEditContainer = document.createElement("div"); + $target.appendChild($postSideBarContainer); + $target.appendChild($postEditContainer); + $postSideBarContainer.className = "post-side-bar-container"; + $postEditContainer.className = "post-edit-container"; + + const postSideBar = new PostSidebar({ + $target: $postSideBarContainer, + }); + + const postEditPage = new PostEditPage({ + $target: $postEditContainer, + initialState: { + postId: "new", + post: { + title: "", + content: "", + }, + }, + }); + + this.route = () => { + const { pathname } = window.location; + + postSideBar.setState(); + + if (pathname !== "/" && pathname.indexOf("/") === 0) { + const [, , postId] = pathname.split("/"); + postEditPage.setState({ postId }); + } + }; + + this.route(); + + initRouter(() => this.route()); +} diff --git a/src/components/Editor.js b/src/components/Editor.js new file mode 100644 index 00000000..c812978c --- /dev/null +++ b/src/components/Editor.js @@ -0,0 +1,51 @@ +export default function Editor({ + $target, + initialState = { + title: "", + content: "", + }, + onEditing, +}) { + const $editor = document.createElement("div"); + $editor.className = "editor"; + + this.state = initialState; + + let isInitialize = false; + + $target.appendChild($editor); + + this.setState = (nextState) => { + this.state = nextState; + const { title, content } = this.state; + $editor.querySelector("[name=title]").value = title; + $editor.querySelector("[name=content]").value = content; + this.render(); + }; + + this.render = () => { + if (!isInitialize) { + $editor.innerHTML = ` + + + `; + isInitialize = true; + } + }; + this.render(); + + $editor.addEventListener("keyup", (e) => { + const { target } = e; + + const name = target.getAttribute("name"); + + if (this.state[name] !== undefined) { + const nextState = { + ...this.state, + [name]: target.value, + }; + this.setState(nextState); + onEditing(this.state); + } + }); +} diff --git a/src/components/PostEditPage.js b/src/components/PostEditPage.js new file mode 100644 index 00000000..60277afa --- /dev/null +++ b/src/components/PostEditPage.js @@ -0,0 +1,62 @@ +import { getData, putData } from "../utils/api.js"; +import Editor from "./Editor.js"; +import { pushRouter } from "../utils/router.js"; + +export default function PostEditPage({ $target, initialState }) { + const INTERVAL_SAVE_TIME = 2000; + + this.state = initialState; + + let timer = null; + + const editor = new Editor({ + $target, + initialState: { + title: "", + content: "", + }, + onEditing: (post) => { + if (timer !== null) { + clearTimeout(timer); + } + + timer = setTimeout(async () => { + const { postId } = this.state; + if (postId && postId !== "new") { + await putData(postId, post); + pushRouter(`/documents/${postId}`); + await getDocument(); + } + }, INTERVAL_SAVE_TIME); + }, + }); + + this.setState = async (nextState) => { + if (this.state.postId !== nextState.postId) { + this.state = nextState; + await getDocument(); + return; + } else { + this.state = nextState; + } + + editor.setState( + this.state.post || { + title: "", + content: "", + } + ); + }; + + const getDocument = async () => { + const { postId } = this.state; + if (postId) { + const post = await getData(`/documents/${postId}`); + + this.setState({ + ...this.state, + post, + }); + } + }; +} diff --git a/src/components/PostItem.js b/src/components/PostItem.js new file mode 100644 index 00000000..5f9160c1 --- /dev/null +++ b/src/components/PostItem.js @@ -0,0 +1,54 @@ +import { deleteData, getData, postData } from "../utils/api.js"; +import { pushRouter } from "../utils/router.js"; + +export default function PostItem(title, id) { + const $postItemBox = document.createElement("div"); + $postItemBox.className = id; + + const $li = document.createElement("li"); + $li.className = id; + + const $title = document.createElement("span"); + $title.className = id; + $title.textContent = title; + $li.appendChild($title); + + const $addButton = makeButton("+", id); + $li.appendChild($addButton); + + const $removeButton = makeButton("-", id); + $li.appendChild($removeButton); + + const $postSubItemBox = document.createElement("ul"); + + $postItemBox.appendChild($li); + $postItemBox.append($postSubItemBox); + + $li.addEventListener("click", async (e) => { + const target = e.target; + if (target.closest("span") === $title) { + pushRouter(`/documents/${$title.className}`); + } else if (target.closest("button") === $addButton) { + const createdPost = await postData($addButton.className); + pushRouter(`/documents/${createdPost.id}`); + } else if (target.closest("button") === $removeButton) { + alert("문서가 정상적으로 삭제되었습니다."); + await deleteData($removeButton.className).then((res) => { + if (res.parent) pushRouter(`/documents/${res.parent.id}`); + else { + pushRouter(`/`); + location.reload(); + } + }); + } + }); + + return { $postItemBox, $postSubItemBox }; +} + +export const makeButton = (text, className) => { + const $button = document.createElement("button"); + $button.textContent = text; + $button.className = className; + return $button; +}; diff --git a/src/components/PostList.js b/src/components/PostList.js new file mode 100644 index 00000000..78343256 --- /dev/null +++ b/src/components/PostList.js @@ -0,0 +1,32 @@ +import PostItem from "./PostItem.js"; + +export default function PostList({ $target, initialState }) { + const $postList = document.createElement("ul"); + $target.appendChild($postList); + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + this.render(); + }; + + this.makeList = ($itemContainer, data) => { + data.forEach(({ title, documents, id }) => { + const { $postItemBox, $postSubItemBox } = PostItem(title, id); + + $itemContainer.appendChild($postItemBox); + + if (documents.length > 0) { + this.makeList($postSubItemBox, documents); + } + }); + }; + + this.render = () => { + $postList.innerHTML = ""; + this.makeList($postList, this.state); + }; + + this.render(); +} diff --git a/src/components/PostSidebar.js b/src/components/PostSidebar.js new file mode 100644 index 00000000..15cd2d04 --- /dev/null +++ b/src/components/PostSidebar.js @@ -0,0 +1,36 @@ +import { getData, postData } from "../utils/api.js"; +import PostList from "./PostList.js"; +import { pushRouter } from "../utils/router.js"; + +export default function PostSidebar({ $target }) { + const $listContainer = document.createElement("div"); + const $createButton = document.createElement("button"); + const $title = document.createElement("h1"); + $listContainer.className = "post-list-container"; + $createButton.className = "create-button"; + $title.className = "title"; + + const postList = new PostList({ + $target: $listContainer, + initialState: [], + }); + + this.setState = async () => { + const documents = await getData("/documents"); + postList.setState(documents); + this.render(); + }; + + this.render = async () => { + $createButton.textContent = "문서 생성하기"; + $title.textContent = "Notion Project"; + $target.appendChild($title); + $target.appendChild($createButton); + $target.appendChild($listContainer); + }; + + $createButton.addEventListener("click", async () => { + const createdPost = await postData(); + pushRouter(`/documents/${createdPost.id}`); + }); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 00000000..9db98264 --- /dev/null +++ b/src/main.js @@ -0,0 +1,6 @@ +import App from "./components/App.js"; + +const $target = document.querySelector("#app"); +$target.className = "contentWrap"; + +new App({ $target }); diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 00000000..aa20fd3f --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,49 @@ +export const API_END_POINT = "https://kdt-frontend.programmers.co.kr"; +export const API_X_USERNAME = "API_X_USERNAME_LIMJISEON"; + +export const request = async (url, options = {}) => { + try { + const response = await fetch(`${API_END_POINT}${url}`, { + ...options, + headers: { + "Content-Type": "application/json", + "x-username": API_X_USERNAME, + }, + }); + + if (response.ok) { + return await response.json(); + } + + throw new Error("API 처리중 에러가 발생했습니다."); + } catch (e) { + console.error(e); + } +}; + +export const getData = async (url) => { + return request(url); +}; + +export const postData = async (id = null) => { + return request("/documents", { + method: "POST", + body: JSON.stringify({ + title: "제목 없음", + parent: id, + }), + }); +}; + +export const deleteData = async (id) => { + return request(`/documents/${id}`, { + method: "DELETE", + }); +}; + +export const putData = async (id, post) => { + return request(`/documents/${id}`, { + method: "PUT", + body: JSON.stringify(post), + }); +}; diff --git a/src/utils/router.js b/src/utils/router.js new file mode 100644 index 00000000..06650aee --- /dev/null +++ b/src/utils/router.js @@ -0,0 +1,23 @@ +const ROUTE_CHANGE_EVENT_NAME = "route-change"; + +export const initRouter = (onRoute) => { + window.addEventListener(ROUTE_CHANGE_EVENT_NAME, (e) => { + const { nextUrl } = e.detail; + + if (nextUrl) { + window.addEventListener("popstate", () => onRoute()); + history.pushState(null, null, nextUrl); + onRoute(); + } + }); +}; + +export const pushRouter = (nextUrl) => { + window.dispatchEvent( + new CustomEvent(ROUTE_CHANGE_EVENT_NAME, { + detail: { + nextUrl, + }, + }) + ); +}; diff --git a/style.css b/style.css new file mode 100644 index 00000000..f062f0b0 --- /dev/null +++ b/style.css @@ -0,0 +1,89 @@ +body { + margin: 0; + padding: 0; + background-color: rgb(229, 229, 229); +} + +button { + border: none; + background-color: transparent; + font-size: 16px; + margin: 0px 5px 0px 5px; + cursor: pointer; +} + +span { + cursor: pointer; +} + +button:hover { + background-color: white; + border-radius: 5px; + font-weight: bolder; +} + +li { + padding: 10px 5px 10px 5px; +} + +li:hover { + background-color: white; + border-radius: 5px; + font-weight: bold; +} + +input { + width: 50vw; + height: 5vh; + border-radius: 5px; + font-size: 16px; + border: none; + outline: none; +} + +textarea { + width: 50vw; + height: 50vh; + border-radius: 5px; + font-size: 16px; + border: none; + outline: none; + margin-top: 10px; +} + +.contentWrap { + width: 100%; + height: 100%; + display: flex; + justify-content: space-between; +} + +.post-edit-container { + width: 70vw; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.post-side-bar-container { + width: 70vw; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.create-button { + border-radius: 5px; + padding: 10px; + background-color: white; +} + +.editor { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0px 50px 0px 50px; +}