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