diff --git a/index.html b/index.html new file mode 100644 index 00000000..129fe0b8 --- /dev/null +++ b/index.html @@ -0,0 +1,10 @@ + + + Star notion + + + +
+ + + diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..a929108e --- /dev/null +++ b/src/App.js @@ -0,0 +1,42 @@ +import Sidebar from "./components/sidebar/Sidebar.js"; +import PostEditPage from "./components/posts/PostEditPage.js"; +import { initRouter, push } from "./utils/router.js"; + +export default function App({ $target }) { + const $sidebar = document.createElement("div"); + const $postEditPage = document.createElement("div"); + + $target.appendChild($sidebar); + $target.appendChild($postEditPage); + + const sidebar = new Sidebar({ $target: $sidebar }); + + const postEditPage = new PostEditPage({ + $target: $postEditPage, + initialState: { + postId: "new", + post: { + title: "", + content: "", + }, + }, + }); + + // const fetchNewPost = async (postId) => { + // push(`/documents/${postId}`); + // }; + + this.route = () => { + const { pathname } = window.location; + if (pathname.indexOf("/documents/") === 0) { + const [, , postId] = pathname.split("/"); + postEditPage.setState({ postId }); + } else { + sidebar.setState(); + } + }; + + this.route(); + + initRouter(() => this.route()); +} diff --git a/src/components/posts/Editor.js b/src/components/posts/Editor.js new file mode 100644 index 00000000..ae8551d7 --- /dev/null +++ b/src/components/posts/Editor.js @@ -0,0 +1,44 @@ +export default function Editor({ + $target, + initialState = { + title: "", + content: "", + }, + onEditing, +}) { + const $editor = document.createElement("div"); + $editor.className = "editor"; + $target.appendChild($editor); + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + $editor.querySelector("[name=title]").value = this.state.title; + $editor.querySelector("[name=content]").innerHTML = this.state.content; + }; + + this.render = () => { + $editor.innerHTML = ` + + + `; + }; + + 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/posts/PostEditPage.js b/src/components/posts/PostEditPage.js new file mode 100644 index 00000000..9edac232 --- /dev/null +++ b/src/components/posts/PostEditPage.js @@ -0,0 +1,122 @@ +import { request } from "../../utils/api.js"; +import { getItem, removeItem, setItem } from "../../utils/storage.js"; +import Editor from "./Editor.js"; + +export default function PostEditPage({ $target, initialState }) { + const $postEditPage = document.createElement("div"); + $postEditPage.className = "post-edit-page"; + + this.state = initialState; + + let postLocalSaveKey = `temp-post-${this.state.postId}`; + + const post = getItem(postLocalSaveKey, { + title: "", + content: "", + }); + + let timer = null; + + const editor = new Editor({ + $target: $postEditPage, + initialState: post, + + onEditing: (post) => { + /* 연속으로 입력을 하고 있을 때는 계속 이벤트 발생을 지연시키다가 + 입력을 멈췄을 때, 즉, 마지막으로 이벤트가 발생하고 일정 시간이 지났을 때 + 지연시켰던 이벤트를 실행시키는 것 - 디바운스 + 디바운스를 이용하면 이벤트 발생하는 횟수를 줄일 수 있다. -> 성능, 최적화 + */ + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(async () => { + setItem(postLocalSaveKey, { + ...post, + tempSaveDate: new Date(), + }); + + const isNew = this.state.postId === "new"; + if (isNew) { + const createdPost = await request("/documents", { + method: "POST", + body: JSON.stringify(post), + }); + history.replaceState(null, null, `/documents/${createdPost.id}`); + removeItem(postLocalSaveKey); + + this.setState({ + postId: createdPost.id, + }); + } else { + await request(`/documents/${post.id}`, { + method: "PUT", + body: JSON.stringify(post), + }); + removeItem(postLocalSaveKey); + } + }, 1000); + }, + }); + + this.setState = async (nextState) => { + if (this.state.postId !== nextState.postId) { + postLocalSaveKey = `temp-post-${nextState.postId}`; + this.state = nextState; + + if (this.state.postId === "new") { + const post = getItem(postLocalSaveKey, { + title: "", + content: "", + }); + this.render(); + editor.setState(post); + } else { + await fetchPost(); + } + return; + } + + this.state = nextState; + this.render(); + + editor.setState( + this.state.post || { + title: "", + content: "", + } + ); + }; + + this.render = () => { + $target.appendChild($postEditPage); + }; + + const fetchPost = async () => { + const { postId } = this.state; + + if (postId !== "new") { + const post = await request(`/documents/${[postId]}`); + + const tempPost = getItem(postLocalSaveKey, { + title: "", + content: "", + }); + + if (tempPost.tempSaveDate && tempPost.tempSaveDate > post.updated_at) { + if (confirm("저장되지 않은 임시 데이터가 있습니다. 불러올까요?")) { + this.setState({ + ...this.state, + post: tempPost, + }); + return; + } + } + + this.setState({ + ...this.state, + post, + }); + } + }; +} diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js new file mode 100644 index 00000000..28a8bbca --- /dev/null +++ b/src/components/sidebar/sidebar.js @@ -0,0 +1,39 @@ +import SidebarHeader from "./SidebarHeader.js"; +import SidebarBody from "./SidebarBody.js"; +import SidebarFooter from "./SidebarFooter.js"; +import { request } from "../../utils/api.js"; + +export default function Sidebar({ $target }) { + const $sidebar = document.createElement("div"); + $sidebar.className = "sidebar"; + const $sidebarHeader = document.createElement("div"); + const $sidebarBody = document.createElement("div"); + const $sidebarFooter = document.createElement("div"); + + const sidebarBody = new SidebarBody({ + $target: $sidebarBody, + }); + + this.setState = () => { + sidebarBody.setState(); + }; + + new SidebarHeader({ + $target: $sidebarHeader, + setState: this.setState(), + }); + + new SidebarFooter({ + $target: $sidebarFooter, + setState: this.setState(), + }); + + this.render = () => { + $target.appendChild($sidebar); + $sidebar.appendChild($sidebarHeader); + $sidebar.appendChild($sidebarBody); + $sidebar.appendChild($sidebarFooter); + }; + + this.render(); +} diff --git a/src/components/sidebar/sidebarBody.js b/src/components/sidebar/sidebarBody.js new file mode 100644 index 00000000..5c89d095 --- /dev/null +++ b/src/components/sidebar/sidebarBody.js @@ -0,0 +1,87 @@ +import { request } from "../../utils/api.js"; +import { push } from "../../utils/router.js"; + +export default function SidebarBody({ $target }) { + const $sidebarBody = document.createElement("div"); + const $renderList = document.createElement("div"); + $sidebarBody.className = "sidebar-body"; + $target.appendChild($sidebarBody); + + this.setState = async () => { + this.state = await request("/documents", { + method: "GET", + }); + $renderList.innerHTML = ""; + this.render(); + }; + + const addDocumnet = async (dataId) => { + const newDocument = await request("/documents", { + method: "POST", + body: JSON.stringify({ + title: "제목", + parent: dataId, + }), + }); + $renderList.innerHTML = ""; + this.setState(); + push(`/documents/${newDocument.id}`); + }; + + const deleteDocument = async (dataId) => { + await request(`/documents/${dataId}`, { + method: "DELETE", + }); + $renderList.innerHTML = ""; + push("/"); + }; + + const renderDocuments = (documents, $renderList) => { + documents.map((e) => { + const $ul = document.createElement("ul"); + const $li = document.createElement("li"); + $li.className = "document-li"; + $li.setAttribute("data-id", e.id); + $li.textContent = e.title; + const $addBtn = document.createElement("button"); + $addBtn.className = "add-btn"; + $addBtn.innerHTML = "+"; + const $deleteBtn = document.createElement("button"); + $deleteBtn.className = "delete-btn"; + $deleteBtn.innerHTML = "x"; + + $renderList.appendChild($ul); + $ul.appendChild($li); + $li.appendChild($addBtn); + $li.appendChild($deleteBtn); + + if (e.documents) { + renderDocuments(e.documents, $ul); + } + }); + + return $renderList.innerHTML; + }; + + this.render = () => { + if (this.state) { + $sidebarBody.innerHTML = renderDocuments(this.state, $renderList); + } else { + $sidebarBody.innerHTML = "새 페이지를 눌러 문서를 작성해 주세요!"; + } + }; + + this.render(); + + $sidebarBody.addEventListener("click", (e) => { + const target = e.target; + const dataId = target.closest("li").dataset.id; + if (target.className === "add-btn") { + addDocumnet(dataId); + } else if (target.className === "delete-btn") { + deleteDocument(dataId); + } else if (dataId) { + push(`/documents/${dataId}`); + } + }); +} diff --git a/src/components/sidebar/sidebarFooter.js b/src/components/sidebar/sidebarFooter.js new file mode 100644 index 00000000..484e7b86 --- /dev/null +++ b/src/components/sidebar/sidebarFooter.js @@ -0,0 +1,33 @@ +import { request } from "../../utils/api.js"; +import { push } from "../../utils/router.js"; + +export default function SidebarFooter({ $target, setState }) { + const $sidebarFooter = document.createElement("div"); + $sidebarFooter.className = "sidebar-footer"; + $target.appendChild($sidebarFooter); + + this.render = () => { + $sidebarFooter.innerHTML = ` +
+ + 새 페이지 +
+ `; + }; + + this.render(); + + const addNewDocument = async () => { + const newDocument = await request("/documents", { + method: "POST", + body: JSON.stringify({ + title: "제목", + parent: null, + }), + }); + setState; + push("/"); + push(`/documents/${newDocument.id}`); + }; + + $sidebarFooter.addEventListener("click", addNewDocument); +} diff --git a/src/components/sidebar/sidebarHeader.js b/src/components/sidebar/sidebarHeader.js new file mode 100644 index 00000000..7a6ad7ab --- /dev/null +++ b/src/components/sidebar/sidebarHeader.js @@ -0,0 +1,22 @@ +import { push } from "../../utils/router.js"; + +export default function SidebarHeader({ $target, setState }) { + const $sidebarHeader = document.createElement("div"); + $sidebarHeader.className = "sidebar-header"; + $target.appendChild($sidebarHeader); + + this.render = () => { + $sidebarHeader.innerHTML = ` +
+

최별의 Notion

+
+ `; + }; + + this.render(); + + $sidebarHeader.addEventListener("click", (e) => { + setState; + push("/"); + }); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 00000000..3876e42a --- /dev/null +++ b/src/main.js @@ -0,0 +1,5 @@ +import App from "./App.js"; + +const $target = document.querySelector("#app"); + +new App({ $target }); diff --git a/src/style/style.css b/src/style/style.css new file mode 100644 index 00000000..7555a9e2 --- /dev/null +++ b/src/style/style.css @@ -0,0 +1,133 @@ +html, +body { + box-sizing: border-box; + margin: 0; +} + +#app { + display: flex; + height: 100vh; +} + +button { + display: relative; + cursor: pointer; + border: 0; + padding: 0; + font-size: 1rem; + background: none; + user-select: none; +} + +ul { + padding: 0 15; + margin: 5 0; +} + +/* + sidebar 배경색: rgb(250, 250, 249) + sidebar hover 색: rgb(230, 230, 230) + sidebar 눌러진 페이지: rgb(238, 238, 238) +*/ + +/* sidebar */ +.sidebar { + position: relative; + width: 240px; + margin: 0; + overflow: hidden; + background-color: rgb(250, 250, 249); +} + +.sidebar-header { + position: flex; + padding: 0 15; + cursor: pointer; +} + +.sidebar-body { + overflow-y: scroll; + overflow-x: hidden; + height: 80vh; + padding: 1rem 15; + padding-top: 1rem; +} + +.document-li { + width: 100%; + padding: 5 20; +} + +.document-li:hover { + background-color: rgb(230, 230, 230); +} + +.add-btn { + position: absolute; + padding: 0.5rem 1rem; + right: 0; + margin-top: -0.5rem; + margin-right: 0.5rem; +} + +.delete-btn { + position: absolute; + padding: 0.5rem 1rem; + right: 1.7rem; + margin-top: -0.5rem; + font-size: 0.9rem; +} + +.sidebar-footer { + position: fixed; + width: 240px; + cursor: pointer; + bottom: 0; + padding: 1.5rem 0; + border-top: 1px solid rgb(228, 228, 225); + background: rgb(250, 250, 249); +} + +.sidebar-footer div { + padding: 0 15; +} + +.sidebar-footer:hover { + background-color: rgb(230, 230, 230); +} + +/* posts */ +.post-edit-page { + display: flex; + flex-direction: row; + justify-content: center; + width: 100vh; + background: white; +} + +.editor { + position: relative; + border: 0; + cursor: text; + flex-direction: column; + margin-left: 1rem; +} + +.editor-title { + position: relative; + border: 0; + width: 100%; + font-size: 3.5rem; + font-weight: 500; + text-align: center; + padding: 3rem; + margin-top: 2rem; +} + +.editor-content { + border: 0; + width: 100%; + font-size: 1rem; + margin-top: 5vh; + padding: 3rem; +} diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 00000000..f1067df9 --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,20 @@ +import { NOTION_API } from "./url.js"; + +export const request = async (url, options = {}) => { + try { + const res = await fetch(`${NOTION_API}${url}`, { + ...options, + headers: { + "Content-Type": "application/json", + "x-username": "starchoi", + }, + }); + + if (res.ok) { + return await res.json(); + } + throw new Error("API 처리 중 뭔가 이상합니다!"); + } catch (e) { + alert(e.message); + } +}; diff --git a/src/utils/router.js b/src/utils/router.js new file mode 100644 index 00000000..145f6caa --- /dev/null +++ b/src/utils/router.js @@ -0,0 +1,21 @@ +const ROUTE_CHANGE_EVENT_NAME = "route-change"; + +export const initRouter = (onRoute) => { + window.addEventListener(ROUTE_CHANGE_EVENT_NAME, (e) => { + const { nextUrl } = e.detail; + if (nextUrl) { + history.pushState(null, null, nextUrl); + onRoute(); + } + }); +}; + +export const push = (nextUrl) => { + window.dispatchEvent( + new CustomEvent(ROUTE_CHANGE_EVENT_NAME, { + detail: { + nextUrl, + }, + }) + ); +}; diff --git a/src/utils/storage.js b/src/utils/storage.js new file mode 100644 index 00000000..5523516f --- /dev/null +++ b/src/utils/storage.js @@ -0,0 +1,18 @@ +const storage = window.localStorage; + +export const getItem = (key, defaultValue) => { + try { + const storedValue = storage.getItem(key); + return storedValue ? JSON.parse(storedValue) : defaultValue; + } catch (e) { + return defaultValue; + } +}; + +export const setItem = (key, value) => { + storage.setItem(key, JSON.stringify(value)); +}; + +export const removeItem = (key) => { + storage.removeItem(key); +};