diff --git a/README.assets/yenotion.png b/README.assets/yenotion.png new file mode 100644 index 00000000..2f3bc3c2 Binary files /dev/null and b/README.assets/yenotion.png differ diff --git a/README.assets/yenotion_wireframe (1).png b/README.assets/yenotion_wireframe (1).png new file mode 100644 index 00000000..000b4978 Binary files /dev/null and b/README.assets/yenotion_wireframe (1).png differ diff --git a/README.md b/README.md index 82b85e81..46b9e4ce 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,87 @@ -# ๐Ÿ“Œ 4์ฃผ์ฐจ ํ”„๋กœ์ ํŠธ[Project1] +# ๐Ÿ“YENOTION -## ํ•„์ˆ˜ ํ”„๋กœ์ ํŠธ -- ํ”„๋กœ์ ํŠธ ๊ธฐํ•œ - - ํ”„๋กœ์ ํŠธ ์ˆ˜ํ–‰ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 8์ผ(ํ™”) ~ 2022๋…„ 11์›” 16์ผ(์ˆ˜) - - ๋ฉ˜ํ‹ฐ ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 17์ผ(๋ชฉ) ~ 2022๋…„ 11์›” 20์ผ(์ผ) - - ๋ฉ˜ํ†  ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 17์ผ(๋ชฉ) ~ 2022๋…„ 11์›” 22์ผ(ํ™”) - - ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๋ฐ˜์˜ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 23์ผ(์ˆ˜) ~ 2022๋…„ 11์›” 25์ผ(๊ธˆ) -- ๋‚ด์šฉ - - **Day 17 [ํ”„๋กœ์ ํŠธ] ๋…ธ์…˜ ํด๋กœ๋‹ ์š”๊ตฌ์‚ฌํ•ญ** ํ™•์ธ ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค. + +## ๐Ÿ”ธ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ ๋ฐ ์ˆ˜ํ–‰ ๊ธฐ๊ฐ„ + +* API๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฌธ์„œ ์ž๋™ ์ €์žฅ ํŽธ์ง‘๊ธฐ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ํ•„์ˆ˜ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ ํ›„ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ์ž์œ ๋กญ๊ฒŒ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. + +> - ํ”„๋กœ์ ํŠธ ์ˆ˜ํ–‰ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 8์ผ(ํ™”) ~ 2022๋…„ 11์›” 16์ผ(์ˆ˜) +> - ๋ฉ˜ํ‹ฐ ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 17์ผ(๋ชฉ) ~ 2022๋…„ 11์›” 20์ผ(์ผ) +> - ๋ฉ˜ํ†  ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 17์ผ(๋ชฉ) ~ 2022๋…„ 11์›” 22์ผ(ํ™”) +> - ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๋ฐ˜์˜ ๊ธฐ๊ฐ„ : 2022๋…„ 11์›” 23์ผ(์ˆ˜) ~ 2022๋…„ 11์›” 25์ผ(๊ธˆ) + +## ๐Ÿ”ธ ๊ตฌ์กฐ ์„ค๊ณ„ + +### ์™€์ด์–ดํ”„๋ ˆ์ž„ + +yenotion_wireframe (1) + + + +### ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ + +yenotion + +## ๐Ÿ”ธ ๊ธฐ๋Šฅ ๋ช…์„ธ + +### ๊ธฐ๋ณธ ๊ธฐ๋Šฅ + +- [x] ๋ฉ”์ธ ํŽ˜์ด์ง€ ์™ผ์ชฝ์— ์ „์ฒด ๋ฌธ์„œ ํŠธ๋ฆฌ๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. + - ๋ฃจํŠธ ๋ฌธ์„œ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์˜ค๋ฅธ์ชฝ ํŽธ์ง‘๊ธฐ ์˜์—ญ์— ๋ฌธ์„œ ๋‚ด์šฉ์„ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค. + - ๋ฌธ์„œ ์ด๋ฆ„์ด ํŠน์ • ๊ธธ์ด๋ฅผ ๋„˜์œผ๋ฉด ํŠน์ • ๊ธธ์ด๊นŒ์ง€ ์ž๋ฅด๊ณ  ... ์ƒ๋žต์œผ๋กœ ํ‘œ์‹œํ•œ๋‹ค. + +- [x] ์ƒˆ๋กœ์šด ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + - ๊ฐ ๋ฌธ์„œ์˜ ์šฐ์ธก์— + ๋ฒ„ํŠผ์ด ์žˆ๊ณ , ํ•ด๋‹น ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ํ•˜์œ„์— ์ƒˆ๋กœ์šด ๋ฌธ์„œ ์ƒ์„ฑ๋˜๊ณ  ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ๋„˜์–ด๊ฐ‘๋‹ˆ๋‹ค. + - ๋ฃจํŠธ ๋ฌธ์„œ ๋˜๋Š” ํŠน์ • ๋ฌธ์„œ ํ•˜์œ„์— ์ƒˆ๋กœ์šด ๋ฌธ์„œ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- [x] ๋ฌธ์„œ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + - ๋ฌธ์„œ ์ €์žฅ ๋ฒ„ํŠผ์„ ๋”ฐ๋กœ ๋งŒ๋“ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + - document save api๋ฅผ ์ด์šฉ, ์„œ๋ฒ„์— ์ง€์†์ ์ธ ์š”์ฒญ์„ ํ†ตํ•ด ์ €์žฅ๋˜๋„๋ก ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฌธ์„œ์˜ ๋‚ด์šฉ์€ ๋งˆํฌ๋‹ค์šด ๋ฌธ๋ฒ•์„ ์‚ฌ์šฉํ•˜์—ฌ ๋‚˜ํƒ€๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + - ์˜ค๋ฅธํŽธ ๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์—์„œ ์‹ค์ œ ํŽ˜์ด์ง€์˜ ๋‚ด์šฉ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- [x] ๋ฌธ์„œ๋ฅผ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- [x] History API๋ฅผ ํ™œ์šฉํ•˜์—ฌ SPA๋กœ ์ œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + - ๋ฃจํŠธ URL: ๋ฌธ์„œ ์„ ํƒ์ด ์•„์ง ์•ˆ ๋œ ์ƒํƒœ๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. + * ํŽธ์ง‘๊ธฐ์—๋Š” ๊ธฐ๋ณธ ํŽ˜์ด์ง€ ์ถœ๋ ฅ + - /documents/{documentId}: ํ•ด๋‹น ๋ฌธ์„œ์˜ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์™€ ํŽธ์ง‘๊ธฐ์— ์ถœ๋ ฅ + + + +### ์ถ”๊ฐ€ ๊ธฐ๋Šฅ + +[๊ณผ์ œ ์ œ์•ˆ] + +- [x] ๊ธฐ๋ณธ์ ์œผ๋กœ ํŽธ์ง‘๊ธฐ๋Š” textarea ๊ธฐ๋ฐ˜์œผ๋กœ ๋‹จ์ˆœํ•œ ํ…์ŠคํŠธ ํŽธ์ง‘๊ธฐ๋กœ ์‹œ์ž‘ํ•˜๋˜, ์—ฌ๋ ฅ์ด ๋˜๋ฉด div์™€ contentEditable์„ ์กฐํ•ฉํ•ด์„œ ์ข€ ๋” Richํ•œ ์—๋””ํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด๋ด…๋‹ˆ๋‹ค. + +- [x] ํŽธ์ง‘๊ธฐ ์ตœํ•˜๋‹จ์—๋Š” ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ๋ฌธ์„œ์˜ ํ•˜์œ„ ๋ฌธ์„œ๋“ค์˜ ๋งํฌ๋ฅผ ์ถ”๊ฐ€. + +- [ ] `ํŽ˜์ด์ง€ ๋ฉ˜์…˜`: ํŽธ์ง‘๊ธฐ ๋‚ด์—์„œ @์‹œ์ž‘ํ•ด์„œ ๋‹ค๋ฅธ ๋ฌธ์„œ์˜ title์„ ์ ์€ ๊ฒฝ์šฐ, ์ž๋™์œผ๋กœ ํ•ด๋‹น Document์˜ ํŽธ์ง‘ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ๋งํฌ๋ฅผ ๊ฑฐ๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + +[๊ธฐํƒ€ ์•„์ด๋””์–ด ์ •๋ฆฌ] + +* ๋งˆํฌ๋‹ค์šด ํŽธ์ง‘๊ธฐ ๋งŒ๋“ค๊ธฐ +* ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ +* ๋‹คํฌ๋ชจ๋“œ +* ์ž„๋ฒ ๋“œ +* ๋ถ๋งˆํฌ +* ํ•˜์ด๋ผ์ดํŒ… +* ์ด๋ฏธ์ง€ ์ž„๋ฒ ๋”ฉ +* ๊ณต์œ ํ•˜๊ธฐ +* undo, redo +* ์ˆ˜์ •์ด๋ ฅ ์ถœ๋ ฅ +* ๊ณผ๊ฑฐ ์ƒํƒœ๋กœ ๋Œ์•„๊ฐ€๊ธฐ + + + +## ๐Ÿ”ธ๋А๋‚€์  + +* ๊ณ„ํš์„ ๊ฑฐ์ฐฝํ•˜๊ฒŒ ์„ธ์šฐ๊ณ  ์‹œ์ž‘ํ–ˆ์ง€๋งŒ, ๊ฒฐ๋ก ์ ์œผ๋กœ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ๋งŽ์ด ๊ตฌํ˜„ํ•˜์ง€ ๋ชปํ–ˆ๋‹ค. ์ผ์ฃผ์ผ ์˜จ์ „ํžˆ ํ”„๋กœ์ ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ–ˆ์œผ๋ฉด ์ง„๋„๋ฅผ ๋” ๋งŽ์ด ๋‚˜๊ฐˆ ์ˆ˜ ์žˆ์—ˆ๊ฒ ์ง€๋งŒ,, ๊ทธ๋ž˜๋„ ๋‹ค์Œ ๊ฐœ๋ฐœ ์ผ์ • ์‚ฐ์ •์€ ๋” ์ž˜ํ•ด๋ณด์ž! +* SPA๋กœ ๊ฐœ๋ฐœ ์‹œ ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ ์‹œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ์  -> express ๊ฐ™์€ ์„œ๋ฒ„๋ฅผ ๋”ฐ๋กœ ๋‘๋Š” ๊ฒƒ์ด ํ•„์š”ํ•  ๊ฒƒ ๊ฐ™๋‹ค. +* ์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ API ์ฃผ์†Œ๋ฅผ ๊ฐ€๋ฆฌ๊ธฐ ์œ„ํ•ด API ์ฃผ์†Œ๋ฅผ ๋‹ด์€ ํŒŒ์ผ์„ gitignore๋กœ ์„ค์ •ํ•˜์—ฌ git์— ์˜ฌ๋ผ๊ฐ€์ง€ ์•Š๋„๋ก ์ฒ˜๋ฆฌํ–ˆ์ง€๋งŒ -> ์ด๋Ÿฐ ๊ฒฝ์šฐ ์–ด๋–ป๊ฒŒ ๋ฐฐํฌ๋ฅผ ํ•  ์ˆ˜ ์žˆ์„์ง€์™€ ๊ฐ™์€ ๊ณ ๋ฏผ๊ณผ, ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ์ •์˜ํ•ด์„œ ์‚ฌ์šฉํ–ˆ์œผ๋ฉด ๋” ์ข‹์•˜์„ ๊ฑฐ ๊ฐ™๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค. +* API ๋น„๋™๊ธฐ ํ†ต์‹  ๋กœ์ง์„ ๋”ฐ๋กœ utils ํ•จ์ˆ˜๋กœ ๋ถ„๋ฆฌํ•ด์ฃผ์—ˆ๋Š”๋ฐ, ํ•จ์ˆ˜๋ฅผ ๊ณ„์† ๋ถ„๋ฆฌํ•˜๋‹ค ๋ณด๋‹ˆ API ์š”์ฒญํ•˜๊ณ  ์‘๋‹ต ๋ฐ›์•„์˜จ ํ›„ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ถ€๋ถ„์—์„œ ์‚ด์ง ๋ฒ„๋ฒ…์˜€๋‹ค.. async await ๊ตฌ๋ฌธ์„ ์‚ฌ์šฉํ•  ๋•Œ ๋ฐ˜ํ™˜๊ฐ’์ด promise์ด๊ธฐ ๋•Œ๋ฌธ์— ๋ฐ”๋กœ ํ•˜์œ„ ์ปดํฌ๋„ŒํŠธ๋กœ ๋„˜๊ธฐ์ง€ ์•Š๊ณ  -> setState๋กœ ๋ถ€๋ชจ์— ์ €์žฅํ•˜๊ณ  ์ดํ›„ ํ•˜์œ„ ์ปดํฌ๋„ŒํŠธ์— ๊ฐ’์„ ์ „๋‹ฌํ–ˆ๋‹ค. -> ์ฒ˜์Œ์— ๋น„๋™๊ธฐ ํ†ต์‹ ํ•˜๋Š” ์ผ๋ จ์˜ ๊ณผ์ •(๋น„๋™๊ธฐ ์š”์ฒญ, ์‘๋‹ต ํ›„ ์ƒํƒœ๊ฐ’ ๋ณ€๊ฒฝ ํ›„ ๋ฆฌ๋ Œ๋”๋ง)์ด ์ž˜ ์•ˆ๋ผ์„œ ๊ผผ๊ผผํžˆ ํ™•์ธํ•˜๋ฉด์„œ ์ž‘์—…ํ–ˆ๋‹ค. +* ๋ฐ”๋‹๋ผ ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋กœ ์ž‘์—…ํ•˜๋Š” ๊ฑด ์žฌ๋ฐŒ๊ฒŒ ๋А๊ปด์ง€๋Š”๋ฐ, css์—์„œ class๋ช… ๊ด€๋ฆฌ ๋ฐ ์Šคํƒ€์ผ ์†์„ฑ ์žฌ์‚ฌ์šฉ, ์ƒํƒœ๊ฐ’ ๊ด€๋ฆฌ, ๋ฆฌ๋ Œ๋”๋ง ๋“ฑ ์‹ ๊ฒฝ์จ์•ผ ํ•˜๋Š” ๊ฒƒ๋“ค์ด ๋งŽ์•„์„œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ, ํ”„๋ ˆ์ž„์›Œํฌ ์‚ฌ์šฉ์ด ํ›จ์”ฌ ํŽธํ•˜๊ธด ํ•˜๋‹ค. + diff --git a/_redirects b/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/index.html b/index.html new file mode 100644 index 00000000..c02ecd07 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + YENOTION + + + + +
+ + + diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..38d74ddf --- /dev/null +++ b/src/App.js @@ -0,0 +1,9 @@ +import Header from "./components/Header.js"; +import DocumentEditPage from "./pages/DocumentEditPage.js"; + +function App({ $target }) { + new Header({ $target }); + new DocumentEditPage({ $target }); +} + +export default App; diff --git a/src/apis/baseUrl.js b/src/apis/baseUrl.js new file mode 100644 index 00000000..6a034320 --- /dev/null +++ b/src/apis/baseUrl.js @@ -0,0 +1,6 @@ +export const API_END_POINT = "https://kdt-frontend.programmers.co.kr"; + +export const HEADERS = { + "x-username": "yezyvibe", + "Content-Type": "application/json", +}; diff --git a/src/apis/documentApi.js b/src/apis/documentApi.js new file mode 100644 index 00000000..1eb8caa6 --- /dev/null +++ b/src/apis/documentApi.js @@ -0,0 +1,26 @@ +import { request } from "./index.js"; + +export const getRootDocuments = async () => { + const response = await request("/documents"); + return response; +}; + +export const createNewDocument = async (data) => { + const response = await request("/documents", "POST", data); + return response; +}; + +export const getDocumentDetail = async (documentId) => { + const response = await request(`/documents/${documentId}`); + return response; +}; + +export const updateDocument = async (documentId, data) => { + const response = await request(`/documents/${documentId}`, "PUT", data); + return response; +}; + +export const deleteDocument = async (documentId) => { + const response = await request(`/documents/${documentId}`, "DELETE"); + return response; +}; diff --git a/src/apis/index.js b/src/apis/index.js new file mode 100644 index 00000000..153b606d --- /dev/null +++ b/src/apis/index.js @@ -0,0 +1,20 @@ +import { API_END_POINT, HEADERS } from "./baseUrl.js"; + +export const request = async (url, method = "GET", data, defaultValue = []) => { + try { + const response = await fetch(API_END_POINT + url, { + method, + headers: HEADERS, + body: JSON.stringify(data), + }); + + if (response.ok) { + const json = await response.json(); + return json; + } + + return defaultValue; + } catch (e) { + console.log(e.message); + } +}; diff --git a/src/components/DocumentEditor.js b/src/components/DocumentEditor.js new file mode 100644 index 00000000..73d76491 --- /dev/null +++ b/src/components/DocumentEditor.js @@ -0,0 +1,90 @@ +import { navigate } from "../utils/router.js"; +import EditorPreview from "./EditorPreview.js"; +import { className } from "../utils/constants.js"; +import { debounce } from "../utils/debounce.js"; + +function DocumentEditor({ $target, initialState, onChange }) { + const { editorDocumentTitle, editorDocumentContent } = className; + + const $editor = document.createElement("section"); + $editor.className = "document-editor"; + $target.appendChild($editor); + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + editorPreview.setState({ + data: this.state.data, + }); + }; + + const editorPreview = new EditorPreview({ + $target, + initialState: { + data: this.state.data, + }, + }); + + const render = () => { + const { title, content, documents } = this.state.data; + + if (!title) { + $editor.innerHTML = ` +

loading..

+ `; + return; + } + + $editor.innerHTML = ` + +
+
${ + content ? content : "" + }
+
+ ${ + documents.length > 0 + ? `
+

๐Ÿ“Œํ•˜์œ„ ๋ฌธ์„œ ๋ชฉ๋ก

+ ${documents + .map(({ id, title }) => `${title}`) + .join("")} +
` + : "" + } + `; + }; + + render(); + + const updateDocument = () => { + const $title = document.querySelector(`.${editorDocumentTitle}`); + const $content = document.querySelector(`.${editorDocumentContent}`); + + onChange($title.value, $content.innerText); + }; + + $editor.addEventListener("keyup", (e) => { + const $div = e.target.closest("div"); + + if ($div) { + debounce(updateDocument, 500); + } + }); + + $editor.addEventListener("click", (e) => { + e.preventDefault(); + + const $a = e.target.closest("a"); + + if ($a) { + const [, , , , documentId] = $a.href.split("/"); + navigate(`/documents/${documentId}`); + } + }); +} + +export default DocumentEditor; diff --git a/src/components/DocumentNode.js b/src/components/DocumentNode.js new file mode 100644 index 00000000..4e463707 --- /dev/null +++ b/src/components/DocumentNode.js @@ -0,0 +1,112 @@ +import { navigate } from "../utils/router.js"; +import { className } from "../utils/constants.js"; + +function DocumentNode({ $target, initialState, onCreate, onDelete }) { + const $node = document.createElement("article"); + $target.appendChild($node); + + const { addButton, deleteButton } = className; + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + this.render(); + }; + + this.render = () => { + const { data } = this.state; + + $node.innerHTML = ` + + `; + }; + + this.render(); + + const onClickButton = ($button) => { + const { + className, + dataset: { parentId }, + } = $button; + + if (!parentId) { + return; + } + + if (className === addButton) { + onCreate(parseInt(parentId)); + return; + } + + if (className === deleteButton) { + onDelete(parentId); + return; + } + }; + + const onSelectDocument = ($li) => { + const { documentId } = $li.dataset; + + if (documentId) { + navigate(`/documents/${documentId}`); + } + }; + + $node.addEventListener("click", (e) => { + const $button = e.target.closest("button"); + if ($button) { + onClickButton($button); + return; + } + + const $li = e.target.closest("li"); + if ($li) { + onSelectDocument($li); + return; + } + }); +} + +export default DocumentNode; diff --git a/src/components/DocumentTree.js b/src/components/DocumentTree.js new file mode 100644 index 00000000..027f383b --- /dev/null +++ b/src/components/DocumentTree.js @@ -0,0 +1,105 @@ +import { + createNewDocument, + deleteDocument, + getRootDocuments, +} from "../apis/documentApi.js"; +import DocumentNode from "./DocumentNode.js"; +import { className } from "../utils/constants.js"; +import { messages } from "../utils/messages.js"; +import { navigate } from "../utils/router.js"; +import { getDocumentIdFromPathname } from "../utils/documentId.js"; + +function DocumentTree({ $target, initialState }) { + const $tree = document.createElement("section"); + $target.appendChild($tree); + + const { treeRootAddButton } = className; + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + this.render(); + }; + + const checkDocumentIdAndRoute = (documentId) => { + if (getDocumentIdFromPathname() === documentId) { + navigate("/"); + } + }; + + this.render = () => { + $tree.innerHTML = ` +
+ add + ์ƒˆ๋กœ์šด ๋ฌธ์„œ +
+ `; + + new DocumentNode({ + $target: $tree, + initialState: { + data: this.state.data, + }, + onCreate: (parentId) => { + this.createDocument(parentId); + }, + onDelete: async (documentId) => { + const answer = confirm(messages.askDelete); + if (!answer) return; + + await deleteDocument(documentId); + this.getData(); + + checkDocumentIdAndRoute(documentId); + }, + }); + }; + + this.getData = async () => { + const data = await getRootDocuments(); + this.setState({ + ...this.state, + data, + }); + }; + + this.createDocument = async (parentId) => { + const title = prompt(messages.askNewTitle); + + if (title === null) { + return; + } + + if (!title.trim()) { + alert(messages.invalidTitle); + return; + } + + const newData = { + title: title.trim(), + parent: parentId, + }; + + const { id } = await createNewDocument(newData); + navigate(`/documents/${id}`); + + this.getData(); + }; + + this.init = () => { + this.getData(); + }; + + this.init(); + + $tree.addEventListener("click", (e) => { + const $div = e.target.closest("div"); + + if ($div?.className === treeRootAddButton) { + this.createDocument(null); + } + }); +} + +export default DocumentTree; diff --git a/src/components/EditorPreview.js b/src/components/EditorPreview.js new file mode 100644 index 00000000..414c2259 --- /dev/null +++ b/src/components/EditorPreview.js @@ -0,0 +1,31 @@ +import { parseMarkdown } from "../utils/parseMarkdown.js"; + +function EditorPreview({ $target, initialState }) { + const $preview = document.createElement("section"); + $preview.className = "editor-preview"; + $target.appendChild($preview); + + this.state = initialState; + + this.setState = (nextState) => { + this.state = nextState; + this.render(); + }; + + this.render = () => { + if (!this.state.data) return; + + const { content } = this.state.data; + + $preview.innerHTML = ` +

๋ฏธ๋ฆฌ๋ณด๊ธฐ

+
${ + content ? parseMarkdown(content) : "๋‚ด์šฉ ์—†์Œ" + }
+ `; + }; + + this.render(); +} + +export default EditorPreview; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..ffb77d4b --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,16 @@ +function Header({ $target }) { + const $title = document.createElement("div"); + $title.className = "header-title"; + $target.appendChild($title); + + this.render = () => { + $title.innerHTML = ` + yenotion-logo +

YENOTION

+ `; + }; + + this.render(); +} + +export default Header; diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..595e10ef --- /dev/null +++ b/src/index.js @@ -0,0 +1,3 @@ +import App from "./App.js"; + +new App({ $target: document.querySelector("#App") }); diff --git a/src/pages/DocumentEditPage.js b/src/pages/DocumentEditPage.js new file mode 100644 index 00000000..5a78774f --- /dev/null +++ b/src/pages/DocumentEditPage.js @@ -0,0 +1,88 @@ +import DocumentEditor from "../components/DocumentEditor.js"; +import DocumentTree from "../components/DocumentTree.js"; +import { getDocumentDetail, updateDocument } from "../apis/documentApi.js"; +import { handleLocationChange } from "../utils/router.js"; +import { getDocumentIdFromPathname } from "../utils/documentId.js"; + +function DocumentEditPage({ $target }) { + const $editPage = document.createElement("div"); + $editPage.className = "main-content"; + $target.appendChild($editPage); + + const $tree = document.createElement("section"); + $tree.className = "document-tree"; + $editPage.appendChild($tree); + + const documentTree = new DocumentTree({ $target: $tree }); + + const $editor = document.createElement("section"); + $editor.className = "document-editor-wrapper"; + $editPage.appendChild($editor); + + this.state = { + data: {}, + }; + + this.setState = (nextState) => { + this.state = nextState; + }; + + this.route = async () => { + const { pathname } = window.location; + const documentId = getDocumentIdFromPathname(); + + if (pathname === "/") { + $editor.innerHTML = ""; + return; + } + + if (documentId) { + $editor.innerHTML = ""; + + const data = await getDocumentDetail(documentId); + this.setState({ + data, + }); + + this.documentEditor = new DocumentEditor({ + $target: $editor, + initialState: { + data: this.state.data, + }, + onChange: (newTitle, newContent) => { + this.updateData(documentId, { + title: newTitle, + content: newContent, + }); + }, + }); + } + }; + + this.updateData = async (documentId, data) => { + await updateDocument(documentId, data); + + const newData = await getDocumentDetail(documentId); + + this.documentEditor.setState({ + data: newData, + }); + + documentTree.getData(); + }; + + this.init = () => { + window.addEventListener("onChangeLocation", (e) => + handleLocationChange(e, this.route) + ); + window.addEventListener("popstate", () => { + this.route(); + }); + + this.route(); + }; + + this.init(); +} + +export default DocumentEditPage; diff --git a/src/styles/style.css b/src/styles/style.css new file mode 100644 index 00000000..d385c0cb --- /dev/null +++ b/src/styles/style.css @@ -0,0 +1,168 @@ +body { + margin: 0; + padding: 0; + font-family: "SUIT-Medium"; +} + +@font-face { + font-family: "SUIT-Medium"; + src: url("https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_suit@1.0/SUIT-Medium.woff2") + format("woff2"); + font-weight: normal; + font-style: normal; +} + +.main-content { + display: flex; + width: 100vw; + height: 100vh; +} + +.header-title { + border-bottom: 1px solid rgb(235, 235, 235); + padding-left: 16px; + display: flex; + align-items: center; + gap: 6px; + background-color: lightgoldenrodyellow; +} + +.header-title img { + width: 50px; + height: 50px; +} + +.document-editor-wrapper { + display: flex; + width: 100vw; +} + +.document-editor { + min-width: 40vw; + max-width: 40vw; +} + +.document-tree { + background-color: rgb(251, 251, 250); + min-width: 250px; + max-width: 300px; + overflow: scroll; +} + +.tree-node { + padding-left: 12px; + border-left: 1px solid lightgray; +} + +.node-item { + list-style: none; + margin-top: 6px; + cursor: pointer; +} + +.tree-node-li { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 300px; +} + +.tree-add-button-wrapper { + display: flex; + padding: 16px 10px; + cursor: pointer; + align-items: center; +} + +.node-title { + display: flex; + align-items: center; +} + +.node-title span { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-button-group { + display: flex; +} + +.add-button, +.delete-button { + all: unset; + cursor: pointer; + width: 20px; + height: 20px; + text-align: center; + border-radius: 4px; +} + +.add-button:hover, +.delete-button:hover { + background-color: lightgray; +} + +.add-button span, +.delete-button span { + font-size: small; + color: black; +} + +.eidtor-wrapper { + display: flex; +} + +.editor-document-content { + height: 70vh; + width: 100%; + font-size: 20px; + padding: 32px; + white-space: pre-wrap; + overflow-x: auto; +} + +.editor-document-content::-webkit-scrollbar { + display: none; +} + +.document-editor { + padding: 16px; + display: flex; + flex-direction: column; +} + +.editor-document-title { + height: 50px; + font-size: 32px; + font-weight: bold; + padding: 10px; + border: none; + font-family: SUIT-Medium; +} + +.child-documents-wrapper { + display: flex; + flex-direction: column; +} + +.modal { + display: none; +} + +.editor-preview { + font-size: 20px; + padding: 16px; + border-left: 1px solid lightgray; +} + +.editor-preview h1 { + font-size: 32px; +} + +.preview-title { + margin: 0; + padding: 10px; +} diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 00000000..d30218cc --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,8 @@ +export const className = { + editorDocumentTitle: "editor-document-title", + editorDocumentContent: "editor-document-content", + treeRootAddButton: "tree-add-button-wrapper", + + addButton: "add-button", + deleteButton: "delete-button", +}; diff --git a/src/utils/debounce.js b/src/utils/debounce.js new file mode 100644 index 00000000..d748b372 --- /dev/null +++ b/src/utils/debounce.js @@ -0,0 +1,11 @@ +let timeoutId = null; + +export const debounce = (callback, delay) => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + callback(); + }, delay); +}; diff --git a/src/utils/documentId.js b/src/utils/documentId.js new file mode 100644 index 00000000..2af74ec2 --- /dev/null +++ b/src/utils/documentId.js @@ -0,0 +1,4 @@ +export const getDocumentIdFromPathname = () => { + const [, , currentDocumentId] = location.pathname.split("/"); + return currentDocumentId; +}; diff --git a/src/utils/messages.js b/src/utils/messages.js new file mode 100644 index 00000000..0b4e0a82 --- /dev/null +++ b/src/utils/messages.js @@ -0,0 +1,5 @@ +export const messages = { + askDelete: "๋ฌธ์„œ๋ฅผ ์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + askNewTitle: "์ƒˆ๋กœ์šด ๋ฌธ์„œ์˜ ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", + invalidTitle: "์˜ฌ๋ฐ”๋ฅธ ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", +}; diff --git a/src/utils/parseMarkdown.js b/src/utils/parseMarkdown.js new file mode 100644 index 00000000..0e2a93bd --- /dev/null +++ b/src/utils/parseMarkdown.js @@ -0,0 +1,62 @@ +export const parseMarkdown = (text) => { + //ul + text = text.replace(/^\s*\n\*/gm, "\n\n$2"); + text = text.replace(/^\*(.+)/gm, "
  • $1
  • "); + + //ol + text = text.replace(/^\s*\n\d\./gm, "
      \n1."); + text = text.replace(/^(\d\..+)\s*\n([^\d\.])/gm, "$1\n
    \n\n$2"); + text = text.replace(/^\d\.(.+)/gm, "
  • $1
  • "); + + //blockquote + text = text.replace(/^\>(.+)/gm, "
    $1
    "); + + //h + text = text.replace(/[\#]{6}(.+)/g, "
    $1
    "); + text = text.replace(/[\#]{5}(.+)/g, "
    $1
    "); + text = text.replace(/[\#]{4}(.+)/g, "

    $1

    "); + text = text.replace(/[\#]{3}(.+)/g, "

    $1

    "); + text = text.replace(/[\#]{2}(.+)/g, "

    $1

    "); + text = text.replace(/[\#]{1}(.+)/g, "

    $1

    "); + + //alt h + text = text.replace(/^(.+)\n\=+/gm, "

    $1

    "); + text = text.replace(/^(.+)\n\-+/gm, "

    $1

    "); + + //images + text = text.replace( + /\!\[([^\]]+)\]\(([^\)]+)\)/g, + '$1' + ); + + //links + text = text.replace( + /[\[]{1}([^\]]+)[\]]{1}[\(]{1}([^\)\"]+)(\"(.+)\")?[\)]{1}/g, + '$1' + ); + + //font styles + text = text.replace(/[\*\_]{2}([^\*\_]+)[\*\_]{2}/g, "$1"); + text = text.replace(/[\*\_]{1}([^\*\_]+)[\*\_]{1}/g, "$1"); + text = text.replace(/[\~]{2}([^\~]+)[\~]{2}/g, "$1"); + + //pre + text = text.replace(/^\s*\n\`\`\`(([^\s]+))?/gm, '
    ');
    +  text = text.replace(/^\`\`\`\s*\n/gm, "
    \n\n"); + + //code + text = text.replace(/[\`]{1}([^\`]+)[\`]{1}/g, "$1"); + + //p + text = text.replace(/^\s*(\n)?(.+)/gm, function (m) { + return /\<(\/)?(h\d|ul|ol|li|blockquote|pre|img)/.test(m) + ? m + : "

    " + m + "

    "; + }); + + //strip p from pre + text = text.replace(/(\)\s*\n\(.+)\<\/p\>/gm, "$1$2"); + + return text; +}; diff --git a/src/utils/router.js b/src/utils/router.js new file mode 100644 index 00000000..c57133e5 --- /dev/null +++ b/src/utils/router.js @@ -0,0 +1,19 @@ +export const handleLocationChange = (e, route) => { + const { href } = e.detail; + const { pathname } = window.location; + + if (pathname === href) return; + + window.history.pushState(null, null, href); + route(); +}; + +export const navigate = (url) => { + const locationChangeEvent = new CustomEvent("onChangeLocation", { + composed: true, + detail: { + href: url, + }, + }); + window.dispatchEvent(locationChangeEvent); +};