├── .env.sample ├── .github ├── ISSUE_TEMPLATE │ ├── daily-scrum---wrap-up-template.md │ └── feature-request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deployment.yml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .env.sample ├── .eslintignore ├── .eslintrc ├── .prettierrc ├── Dockerfile ├── config │ └── webpack.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src │ ├── App.ts │ ├── aops │ │ ├── errorHandler.ts │ │ ├── index.ts │ │ └── transactionHandler.ts │ ├── controllers │ │ ├── block.ts │ │ ├── index.ts │ │ └── page.ts │ ├── middlewares │ │ ├── block.ts │ │ ├── index.ts │ │ └── objectIdValidator.ts │ ├── models │ │ ├── block.ts │ │ ├── index.ts │ │ └── page.ts │ ├── routes │ │ ├── block.ts │ │ ├── index.ts │ │ └── page.ts │ ├── services │ │ ├── block.ts │ │ ├── index.ts │ │ └── page.ts │ ├── socket.ts │ ├── utils │ │ ├── generateId.ts │ │ └── index.ts │ └── www.ts ├── test │ └── services │ │ ├── block.spec.ts │ │ └── page.spec.ts ├── tsconfig.json └── tsconfig.paths.json ├── docker-compose.dev.yml ├── docker-compose.yml └── frontend ├── .dockerignore ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── Dockerfile ├── README.md ├── config-overrides.js ├── nginx └── nginx.conf ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── assets │ ├── bulletedList.png │ ├── check.svg │ ├── dots.svg │ ├── doubleChevronLeft.svg │ ├── doubleChevronRight.svg │ ├── draggable.svg │ ├── hamburgerMenu.svg │ ├── heading1.png │ ├── heading2.png │ ├── heading3.png │ ├── loading.svg │ ├── numberedList.png │ ├── plus.svg │ ├── plusPage.svg │ ├── quote.png │ ├── text.png │ ├── toggle-default.svg │ ├── toggle-down.svg │ ├── toggledList.png │ └── trash.svg ├── components │ ├── atoms │ │ ├── BlockContent │ │ │ ├── BlockContent.stories.tsx │ │ │ ├── BlockContent.tsx │ │ │ └── index.ts │ │ ├── HeaderButton │ │ │ ├── HeaderButton.stories.tsx │ │ │ ├── HeaderButton.test.ts │ │ │ ├── HeaderButton.tsx │ │ │ └── index.ts │ │ ├── HeaderLink │ │ │ ├── HeaderLink.stories.tsx │ │ │ ├── HeaderLink.test.ts │ │ │ ├── HeaderLink.tsx │ │ │ └── index.ts │ │ ├── Heading │ │ │ ├── Heading.stories.tsx │ │ │ ├── Heading.test.ts │ │ │ ├── Heading.tsx │ │ │ └── index.ts │ │ ├── Text │ │ │ ├── Text.stories.tsx │ │ │ ├── Text.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── molecules │ │ ├── BlockComponent │ │ │ ├── BlockComponent.stories.tsx │ │ │ ├── BlockComponent.test.ts │ │ │ ├── BlockComponent.tsx │ │ │ └── index.ts │ │ ├── BlockHandler │ │ │ ├── BlockHandler.stories.tsx │ │ │ ├── BlockHandler.tsx │ │ │ └── index.tsx │ │ ├── BlockModal │ │ │ ├── BlockModal.tsx │ │ │ └── index.ts │ │ ├── Editor │ │ │ ├── Editor.stories.tsx │ │ │ ├── Editor.test.ts │ │ │ ├── Editor.tsx │ │ │ └── index.ts │ │ ├── Header │ │ │ ├── Header.stories.tsx │ │ │ ├── Header.test.ts │ │ │ ├── Header.tsx │ │ │ └── index.ts │ │ ├── HoverArea │ │ │ ├── HoverArea.tsx │ │ │ └── index.ts │ │ ├── Menu │ │ │ ├── Menu.stories.tsx │ │ │ ├── Menu.test.ts │ │ │ ├── Menu.tsx │ │ │ └── index.ts │ │ ├── MenuItem │ │ │ ├── MenuItem.tsx │ │ │ └── index.ts │ │ ├── Title │ │ │ ├── Title.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── organisms │ │ ├── HeaderMenu │ │ │ ├── HeaderMenu.stories.tsx │ │ │ ├── HeaderMenu.test.ts │ │ │ ├── HeaderMenu.tsx │ │ │ └── index.ts │ │ └── index.ts │ └── pages │ │ ├── PageComponent │ │ ├── PageComponent.stories.tsx │ │ ├── PageComponent.test.ts │ │ ├── PageComponent.tsx │ │ └── index.ts │ │ └── index.ts ├── hooks │ ├── index.ts │ ├── useApi.ts │ ├── useBlock.tsx │ ├── useCommand.tsx │ ├── useFamily.tsx │ ├── useManager.tsx │ └── useSocket.tsx ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts ├── schemes.ts ├── setupTests.ts ├── socket.ts ├── stores │ ├── index.ts │ └── page.ts └── utils │ ├── blockApis.ts │ ├── blockContent.tsx │ ├── debounce.ts │ ├── fetchApi.ts │ ├── fetchDummyData.ts │ ├── index.ts │ ├── pageApis.ts │ └── time.ts ├── tsconfig.json └── tsconfig.paths.json /.env.sample: -------------------------------------------------------------------------------- 1 | # mongo config 2 | MONGO_USERNAME= 3 | MONGO_PASSWORD= 4 | MONGO_DATABASE= 5 | 6 | # backend config 7 | BACKEND_PORT= 8 | COOKIE_SECRET= 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/daily-scrum---wrap-up-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Daily Scrum & Wrap Up Template 3 | about: Daily Scrum & Wrap Up Template 4 | title: Day _ Scrum 및 Wrap Up 5 | labels: "\U0001F468‍\U0001F469‍\U0001F467‍\U0001F466 daily scrum & Wrap up, \U0001F4D2 6 | \ document" 7 | assignees: domino8788, skid901, YiSoJeong 8 | 9 | --- 10 | 11 | # Scrum 🏉 12 | 13 | ## 2020. 11. 16. 14 | ### 1. 어제 한 일 🌙 15 | - 16 | ### 2. 오늘 할 일 🔥 17 | - 18 | ### 3. 공유할 이슈 🙌 19 | - 20 | 21 |
22 | 23 | # Wrap-up 🌯 24 | 25 | ## 2020. 11. 16. 26 | ### 1. 오늘의 일정 (2020/11/16 월) 🐣 27 | ``` 28 | 10:00 - 10:15 / Daily Scrum 진행 29 | 10:15 - 12:00 / 30 | 18:30 - 19:00 / Wrap Up 진행 31 | ``` 32 | ### 2. 오늘의 회고 🎈 33 | ``` 34 | 35 | ``` 36 | ### 3. 내일의 일정 (2020/11/17 화) 🐥 37 | ``` 38 | 10:00 - 10:15 / Daily Scrum 진행 39 | 10:15 - 12:00 / 40 | 18:30 - 19:00 / Wrap Up 진행 41 | ``` 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Type] 제목" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 제목을 입력해주세요. 11 | 12 | - 설명1 13 | - 설명2 14 | 15 | ## 완료 조건 ✅ 16 | 17 | - [ ] 완료 조건1 18 | - [ ] 완료 조건2 19 | - [ ] 완료 조건3 20 | 21 | ## 관련 이슈 📎 22 | 23 | 관련 이슈 없음 24 | 25 | ## 레퍼런스 📚 26 | 27 | 레퍼런스 없음 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # 제목 2 | 3 | ## 해당 이슈 📎 4 | 5 | 이슈 제목 및 링크 6 | 7 | ## 변경 사항 🛠 8 | 9 | 구현내용 요약 10 | 11 | - 요약1 12 | - 요약2 13 | 14 | ## 테스트 ✨ 15 | 16 | 없음 17 | 18 | ## 리뷰어 참고 사항 🙋‍♀️ 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Deployment Workflow 2 | on: 3 | push: 4 | branches: [ master ] 5 | 6 | jobs: 7 | 8 | deploy: 9 | name: Deploy 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: deploy production server 13 | uses: appleboy/ssh-action@master 14 | with: 15 | host: ${{ secrets.BOOTION_HOST }} 16 | port: ${{ secrets.BOOTION_PORT }} 17 | username: ${{ secrets.BOOTION_USERNAME }} 18 | key: ${{ secrets.BOOTION_SECRET }} 19 | command_timeout: 200m 20 | script: | 21 | cd Project18-C-Bootion 22 | git pull origin master 23 | docker-compose down --rmi all --remove-orphans 24 | docker-compose up --build -d 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/vscode,intellij,macos,windows,node,react,dotenv 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=vscode,intellij,macos,windows,node,react,dotenv 3 | 4 | ### dotenv ### 5 | .env 6 | 7 | ### Intellij ### 8 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 9 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 10 | 11 | # User-specific stuff 12 | .idea/**/workspace.xml 13 | .idea/**/tasks.xml 14 | .idea/**/usage.statistics.xml 15 | .idea/**/dictionaries 16 | .idea/**/shelf 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/jarRepositories.xml 41 | # .idea/modules.xml 42 | # .idea/*.iml 43 | # .idea/modules 44 | # *.iml 45 | # *.ipr 46 | 47 | # CMake 48 | cmake-build-*/ 49 | 50 | # Mongo Explorer plugin 51 | .idea/**/mongoSettings.xml 52 | 53 | # File-based project format 54 | *.iws 55 | 56 | # IntelliJ 57 | out/ 58 | .idea/ 59 | 60 | # mpeltonen/sbt-idea plugin 61 | .idea_modules/ 62 | 63 | # JIRA plugin 64 | atlassian-ide-plugin.xml 65 | 66 | # Cursive Clojure plugin 67 | .idea/replstate.xml 68 | 69 | # Crashlytics plugin (for Android Studio and IntelliJ) 70 | com_crashlytics_export_strings.xml 71 | crashlytics.properties 72 | crashlytics-build.properties 73 | fabric.properties 74 | 75 | # Editor-based Rest Client 76 | .idea/httpRequests 77 | 78 | # Android studio 3.1+ serialized cache file 79 | .idea/caches/build_file_checksums.ser 80 | 81 | ### Intellij Patch ### 82 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 83 | 84 | # *.iml 85 | # modules.xml 86 | # .idea/misc.xml 87 | # *.ipr 88 | 89 | # Sonarlint plugin 90 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 91 | .idea/**/sonarlint/ 92 | 93 | # SonarQube Plugin 94 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 95 | .idea/**/sonarIssues.xml 96 | 97 | # Markdown Navigator plugin 98 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 99 | .idea/**/markdown-navigator.xml 100 | .idea/**/markdown-navigator-enh.xml 101 | .idea/**/markdown-navigator/ 102 | 103 | # Cache file creation bug 104 | # See https://youtrack.jetbrains.com/issue/JBR-2257 105 | .idea/$CACHE_FILE$ 106 | 107 | # CodeStream plugin 108 | # https://plugins.jetbrains.com/plugin/12206-codestream 109 | .idea/codestream.xml 110 | 111 | ### macOS ### 112 | # General 113 | .DS_Store 114 | .AppleDouble 115 | .LSOverride 116 | 117 | # Icon must end with two \r 118 | Icon 119 | 120 | 121 | # Thumbnails 122 | ._* 123 | 124 | # Files that might appear in the root of a volume 125 | .DocumentRevisions-V100 126 | .fseventsd 127 | .Spotlight-V100 128 | .TemporaryItems 129 | .Trashes 130 | .VolumeIcon.icns 131 | .com.apple.timemachine.donotpresent 132 | 133 | # Directories potentially created on remote AFP share 134 | .AppleDB 135 | .AppleDesktop 136 | Network Trash Folder 137 | Temporary Items 138 | .apdisk 139 | 140 | ### Node ### 141 | # Logs 142 | logs 143 | *.log 144 | npm-debug.log* 145 | yarn-debug.log* 146 | yarn-error.log* 147 | lerna-debug.log* 148 | 149 | # Diagnostic reports (https://nodejs.org/api/report.html) 150 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 151 | 152 | # Runtime data 153 | pids 154 | *.pid 155 | *.seed 156 | *.pid.lock 157 | 158 | # Directory for instrumented libs generated by jscoverage/JSCover 159 | lib-cov 160 | 161 | # Coverage directory used by tools like istanbul 162 | coverage 163 | *.lcov 164 | 165 | # nyc test coverage 166 | .nyc_output 167 | 168 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 169 | .grunt 170 | 171 | # Bower dependency directory (https://bower.io/) 172 | bower_components 173 | 174 | # node-waf configuration 175 | .lock-wscript 176 | 177 | # Compiled binary addons (https://nodejs.org/api/addons.html) 178 | build/Release 179 | 180 | # Dependency directories 181 | node_modules/ 182 | jspm_packages/ 183 | 184 | # TypeScript v1 declaration files 185 | typings/ 186 | 187 | # TypeScript cache 188 | *.tsbuildinfo 189 | 190 | # Optional npm cache directory 191 | .npm 192 | 193 | # Optional eslint cache 194 | .eslintcache 195 | 196 | # Microbundle cache 197 | .rpt2_cache/ 198 | .rts2_cache_cjs/ 199 | .rts2_cache_es/ 200 | .rts2_cache_umd/ 201 | 202 | # Optional REPL history 203 | .node_repl_history 204 | 205 | # Output of 'npm pack' 206 | *.tgz 207 | 208 | # Yarn Integrity file 209 | .yarn-integrity 210 | 211 | # dotenv environment variables file 212 | .env.test 213 | .env*.local 214 | 215 | # parcel-bundler cache (https://parceljs.org/) 216 | .cache 217 | .parcel-cache 218 | 219 | # Next.js build output 220 | .next 221 | 222 | # Nuxt.js build / generate output 223 | .nuxt 224 | dist 225 | 226 | # Gatsby files 227 | .cache/ 228 | # Comment in the public line in if your project uses Gatsby and not Next.js 229 | # https://nextjs.org/blog/next-9-1#public-directory-support 230 | # public 231 | 232 | # vuepress build output 233 | .vuepress/dist 234 | 235 | # Serverless directories 236 | .serverless/ 237 | 238 | # FuseBox cache 239 | .fusebox/ 240 | 241 | # DynamoDB Local files 242 | .dynamodb/ 243 | 244 | # TernJS port file 245 | .tern-port 246 | 247 | # Stores VSCode versions used for testing VSCode extensions 248 | .vscode-test 249 | 250 | ### react ### 251 | .DS_* 252 | **/*.backup.* 253 | **/*.back.* 254 | 255 | node_modules 256 | 257 | *.sublime* 258 | 259 | psd 260 | thumb 261 | sketch 262 | 263 | ### vscode ### 264 | .vscode/ 265 | .vscode/* 266 | !.vscode/settings.json 267 | !.vscode/tasks.json 268 | !.vscode/launch.json 269 | !.vscode/extensions.json 270 | *.code-workspace 271 | 272 | ### Windows ### 273 | # Windows thumbnail cache files 274 | Thumbs.db 275 | Thumbs.db:encryptable 276 | ehthumbs.db 277 | ehthumbs_vista.db 278 | 279 | # Dump file 280 | *.stackdump 281 | 282 | # Folder config file 283 | [Dd]esktop.ini 284 | 285 | # Recycle Bin used on file shares 286 | $RECYCLE.BIN/ 287 | 288 | # Windows Installer files 289 | *.cab 290 | *.msi 291 | *.msix 292 | *.msm 293 | *.msp 294 | 295 | # Windows shortcuts 296 | *.lnk 297 | 298 | # End of https://www.toptal.com/developers/gitignore/api/vscode,intellij,macos,windows,node,react,dotenv 299 | 300 | # mongo 301 | mongo/ 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 부스트캠프 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project18-C-Bootion 2 | 3 |
4 | 5 | 6 | 7 | # Bootion : The Notion Clone 8 | 9 | ![NodeJS](https://img.shields.io/badge/Node.js-v14.4.0-339933?logo=node.js&style=plastic) 10 | ![typescript](https://img.shields.io/badge/typescript-v4.1.2-007acc?logo=typescript&style=plastic) 11 | ![javascript](https://img.shields.io/badge/javascript-ES2020-yellow?logo=javascript&style=plastic) 12 | 13 | ![react](https://img.shields.io/badge/react-v17.0.1-61dafb?logo=React&style=plastic) 14 | ![express](https://img.shields.io/badge/express-v4.17.1-eee?logo=Express&style=plastic) 15 | ![webpack](https://img.shields.io/badge/webpack-v5.6.0-8dd6f9?logo=Webpack&style=plastic) 16 | ![@babel/core](https://img.shields.io/badge/@babel/core-v7.12.7-f9dc3e?logo=Babel&style=plastic) 17 | 18 | ![jest](https://img.shields.io/badge/jest-v26.6.3-c21325?logo=Jest&style=plastic) 19 | ![storybook](https://img.shields.io/badge/storybook-v26.6.3-ff4785?logo=Storybook&style=plastic) 20 | ![Docker](https://img.shields.io/badge/Docker-v20.10.0-2496ed?logo=Docker&style=plastic) 21 | ![MongoDB](https://img.shields.io/badge/MongoDB-latest-47a248?logo=MongoDB&style=plastic) 22 | 23 | [![GitHub Open Issues](https://img.shields.io/github/issues-raw/boostcamp-2020/Project18-C-Bootion?color=green&style=plastic)](https://github.com/boostcamp-2020/Project18-C-Bootion/issues) 24 | [![GitHub Closed Issues](https://img.shields.io/github/issues-closed-raw/boostcamp-2020/Project18-C-Bootion?color=red&style=plastic)](https://github.com/boostcamp-2020/Project18-C-Bootion/issues?q=is%3Aissue+is%3Aclosed) 25 | [![GitHub Open PR](https://img.shields.io/github/issues-pr-raw/boostcamp-2020/Project18-C-Bootion?color=green&style=plastic)](https://github.com/boostcamp-2020/Project18-C-Bootion/pulls) 26 | [![GitHub Closed PR](https://img.shields.io/github/issues-pr-closed-raw/boostcamp-2020/Project18-C-Bootion?color=red&style=plastic)](https://github.com/boostcamp-2020/Project18-C-Bootion/pulls?q=is%3Apr+is%3Aclosed) 27 | 28 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=plastic)](https://opensource.org/licenses/MIT) 29 | [![stars](https://img.shields.io/github/stars/boostcamp-2020/Project18-C-Bootion?style=plastic)](https://img.shields.io/github/stars/boostcamp-2020/Project18-C-Bootion?style=plastic) 30 | 31 |
32 | 33 | ## 🖥 데모 34 | 35 | http://bootion.kro.kr/ 36 | 37 | > drag & drop 38 | 39 | | 형제 "Block" 간 위치 이동 | 부모 - 자식 "Block" 간 위치 이동 | 40 | | --- | --- | 41 | | ![drag-drop-1](https://user-images.githubusercontent.com/37685690/102720921-97455680-433a-11eb-8344-9cf1b236817e.gif) | ![drag-drop-2](https://user-images.githubusercontent.com/37685690/102720922-99a7b080-433a-11eb-86ff-f65d3b669743.gif)| 42 | 43 | > 블록 타입 변경 44 | 45 | | 헤딩 형 "Block" 변경 | 리스트 형 "Block" 변경 | 46 | | --- | --- | 47 | | ![heading](https://user-images.githubusercontent.com/37685690/102721010-336f5d80-433b-11eb-9430-e259f08a4d2d.gif) | ![list](https://user-images.githubusercontent.com/37685690/102721005-2d797c80-433b-11eb-85dd-a497abbec324.gif)| 48 | 49 | > 키보드 Interaction 50 | 51 | | "/" 헤딩 형 "Block" 변경 | "/" 리스트 형 "Block" 변경 | 52 | | --- | --- | 53 | | ![heading-modal](https://user-images.githubusercontent.com/37685690/102721487-6f57f200-433e-11eb-82f7-3d2ac0df28dc.gif) | ![list-modal](https://user-images.githubusercontent.com/37685690/102721490-70891f00-433e-11eb-9ece-5137a0b055d7.gif)| 54 | 55 | | "Enter" 입력 | "Backspace" 입력 | 56 | | --- | --- | 57 | | ![Enter](https://user-images.githubusercontent.com/37685690/102721569-fb6a1980-433e-11eb-994d-74674b94e0e8.gif) | ![backspace](https://user-images.githubusercontent.com/37685690/102721591-176dbb00-433f-11eb-9168-720afefc7da6.gif)| 58 | 59 | | "Tab" 입력 | "Shift" + "Tab" 입력 | 60 | | --- | --- | 61 | | ![tab](https://user-images.githubusercontent.com/37685690/102721646-729fad80-433f-11eb-8165-0595317b5723.gif) | ![shiftTab](https://user-images.githubusercontent.com/37685690/102721647-74697100-433f-11eb-93a1-a6a5775d07be.gif)| 62 | 63 | > 모달 Interaction 64 | 65 | | "+" 버튼 클릭 | 66 | | --- | 67 | | ![block-handler](https://user-images.githubusercontent.com/37685690/102721138-49315280-433c-11eb-8d06-5f257091b9cf.gif)| 68 | 69 | > 실시간 동시 편집 70 | 71 | | | 72 | | --- | 73 | | ![sync](https://user-images.githubusercontent.com/37685690/102721229-eab8a400-433c-11eb-851d-a38feaa2b3a1.gif) | 74 | 75 | ## ⚙️ 기술 스택 76 | 77 |
78 | 79 | 무제 80 | 81 |
82 | 83 | ## 🔗 인프라 구조 84 | 85 |
86 | 87 | 인프라_구조 88 | 89 |
90 | 91 | ## 🗂 프로젝트 구조 92 | 93 | ``` 94 | . 95 | ├── frontend(client) 96 | │ ├── public 97 | │ ├── src 98 | │ │ ├── components 99 | │ │ │ ├── atoms 100 | │ │ │ ├── molecules 101 | │ │ │ ├── organisms 102 | │ │ │ └── pages 103 | │ │ │ 104 | │ │ ├── assets 105 | │ │ ├── hooks 106 | │ │ ├── stores 107 | │ │ ├── utils 108 | │ │ │ 109 | │ │ ├── App.tsx 110 | │ │ ├── schemes.ts 111 | │ │ └── index.tsx 112 | │ │ 113 | │ ├── .storybook 114 | │ │ 115 | │ ├── nginx 116 | │ │ └── nginx.conf 117 | │ │ 118 | │ └── Dockerfile 119 | │ 120 | ├── backend(server) 121 | │ ├── src 122 | │ │ ├── routes 123 | │ │ ├── middlewares 124 | │ │ ├── aops 125 | │ │ ├── controllers 126 | │ │ ├── services 127 | │ │ ├── models 128 | │ │ ├── utils 129 | │ │ │ 130 | │ │ ├── App.ts 131 | │ │ └── www.ts 132 | │ │ 133 | │ ├── test 134 | │ │ └── services 135 | │ │ 136 | │ ├── config 137 | │ │ └── webpack.config.js 138 | │ │ 139 | │ └── Dockerfile 140 | │ 141 | └── docker-compose.yml 142 | ``` 143 | 144 | ## 👩🏻‍💻 참여 개발자 🧑🏻‍💻 145 | 146 | | 👩🏻‍💻 이소정 | 🧑🏻‍💻 김남진 | 🧑🏻‍💻 시경덕 | 147 | | :----: | :----: | :----: | 148 | | 이소정 | 김남진 | 시경덕 | 149 | | [@YiSoJeong](https://github.com/YiSoJeong) | [@domino8788](https://github.com/domino8788) | [@skid901](https://github.com/skid901) | 150 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .pnp.* 118 | 119 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | # mongo config 2 | MONGO_USERNAME= 3 | MONGO_PASSWORD= 4 | MONGO_DATABASE= 5 | 6 | # backend config 7 | BACKEND_PORT= 8 | 9 | # cookie secret 10 | COOKIE_SECRET= -------------------------------------------------------------------------------- /backend/.eslintignore: -------------------------------------------------------------------------------- 1 | config/* 2 | dist/* 3 | node_modules/* 4 | jest.config.js 5 | -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier", 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:prettier/recommended", 9 | "prettier/@typescript-eslint" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "project": "./tsconfig.json" 14 | }, 15 | "env": { 16 | "node": true 17 | }, 18 | "ignorePatterns": [ 19 | "dist/", 20 | "node_modules/" 21 | ], 22 | "rules":{ 23 | "prettier/prettier": [ 24 | "error", 25 | { 26 | "endOfLine":"auto" 27 | } 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "consistent", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "arrowParens": "always" 11 | } 12 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | # 앱 디렉터리 생성 4 | # WORKDIR /usr/src/app 5 | # WORKDIR ./ 6 | 7 | # 앱 의존성 설치 8 | # 가능한 경우(npm@5+) package.json과 package-lock.json을 모두 복사하기 위해 9 | # 와일드카드를 사용 10 | COPY package*.json ./ 11 | 12 | # RUN npm install 13 | RUN npm install 14 | RUN npm install -g pm2 15 | 16 | # 앱 소스 추가 17 | COPY . . 18 | 19 | # build TS to JS 20 | RUN npm run build 21 | 22 | ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh / 23 | RUN chmod +x /wait-for-it.sh 24 | -------------------------------------------------------------------------------- /backend/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | const path = require('path'); 5 | 6 | const resolvePathFromRoot = (...pathSegments) => 7 | path.resolve(__dirname, '..', ...pathSegments); 8 | 9 | module.exports = { 10 | target: 'node', 11 | resolve: { 12 | extensions: ['.ts', '.js'], 13 | alias: { 14 | '@': resolvePathFromRoot('src'), 15 | '@routes': resolvePathFromRoot('src', 'routes'), 16 | '@middlewares': resolvePathFromRoot('src', 'middlewares'), 17 | '@aops': resolvePathFromRoot('src', 'aops'), 18 | '@controllers': resolvePathFromRoot('src', 'controllers'), 19 | '@services': resolvePathFromRoot('src', 'services'), 20 | '@models': resolvePathFromRoot('src', 'models'), 21 | '@utils': resolvePathFromRoot('src', 'utils'), 22 | }, 23 | }, 24 | entry: { 25 | www: resolvePathFromRoot('src', 'www.ts'), 26 | }, 27 | output: { 28 | filename: '[name].js', 29 | path: resolvePathFromRoot('dist'), 30 | }, 31 | devtool: 'source-map', 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.ts$/, 36 | exclude: [ 37 | resolvePathFromRoot('node_modules'), 38 | resolvePathFromRoot('test'), 39 | ], 40 | loader: 'ts-loader', 41 | }, 42 | ], 43 | }, 44 | plugins: [new CleanWebpackPlugin()], 45 | externals: [nodeExternals()], 46 | }; 47 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest/utils'); 2 | 3 | const { compilerOptions } = require('./tsconfig.paths.json'); 4 | 5 | module.exports = { 6 | globals: { 7 | 'ts-jest': { 8 | tsConfigFile: 'tsconfig.json', 9 | enableTsDiagnostics: true, 10 | }, 11 | }, 12 | moduleFileExtensions: ['ts', 'js'], 13 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 14 | prefix: '/', 15 | }), 16 | testEnvironment: 'node', 17 | testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', 18 | transform: { 19 | '^.+\\.(ts|tsx)$': 'ts-jest', 20 | }, 21 | transformIgnorePatterns: ['/node_modules/.*'], 22 | }; 23 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev:db": "docker-compose -f ../docker-compose.dev.yml up -d", 8 | "test": "jest --detectOpenHandles --forceExit", 9 | "start": "nodemon --watch src --delay 1 --exec ts-node -r tsconfig-paths/register src/www.ts", 10 | "build": "webpack --config config/webpack.config.js" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@typescript-eslint/eslint-plugin": "^4.8.1", 17 | "@typescript-eslint/parser": "^4.8.1", 18 | "eslint": "^7.13.0", 19 | "eslint-config-prettier": "^6.15.0", 20 | "eslint-plugin-prettier": "^3.1.4", 21 | "husky": "^4.3.0", 22 | "jest": "^26.6.3", 23 | "lint-staged": "^10.5.2", 24 | "nodemon": "^2.0.6", 25 | "npm-run-all": "^4.1.5", 26 | "prettier": "^2.1.2", 27 | "ts-node": "^9.0.0" 28 | }, 29 | "dependencies": { 30 | "@types/connect-flash": "0.0.36", 31 | "@types/cookie-parser": "^1.4.2", 32 | "@types/express": "^4.17.9", 33 | "@types/express-session": "^1.17.3", 34 | "@types/http-errors": "^1.8.0", 35 | "@types/mongoose": "^5.10.1", 36 | "@types/morgan": "^1.9.2", 37 | "@types/node": "^14.14.10", 38 | "@types/socket.io": "^2.1.12", 39 | "clean-webpack-plugin": "^3.0.0", 40 | "connect-flash": "^0.1.1", 41 | "cookie-parser": "^1.4.5", 42 | "dotenv": "^8.2.0", 43 | "express": "^4.17.1", 44 | "express-session": "^1.17.1", 45 | "http-errors": "^1.8.0", 46 | "mongoose": "^5.10.15", 47 | "morgan": "^1.10.0", 48 | "socket.io": "^3.0.4", 49 | "source-map-support": "^0.5.19", 50 | "ts-jest": "^26.4.4", 51 | "ts-loader": "^8.0.11", 52 | "tsconfig-paths": "^3.9.0", 53 | "typescript": "^4.1.2", 54 | "webpack": "^5.6.0", 55 | "webpack-cli": "^4.2.0", 56 | "webpack-node-externals": "^2.5.2" 57 | }, 58 | "husky": { 59 | "hooks": { 60 | "pre-commit": "lint-staged" 61 | } 62 | }, 63 | "lint-staged": { 64 | "*.{js,ts}": "eslint --fix" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /backend/src/App.ts: -------------------------------------------------------------------------------- 1 | import express, { Application, Request, Response, NextFunction } from 'express'; 2 | import logger from 'morgan'; 3 | import createHttpError, { HttpError } from 'http-errors'; 4 | import session from 'express-session'; 5 | import cookieParser from 'cookie-parser'; 6 | import flash from 'connect-flash'; 7 | import { config } from 'dotenv'; 8 | 9 | import webSocket from '@/socket'; 10 | import { StatusCode, ErrorMessage } from '@/aops'; 11 | import { connect } from '@/models'; 12 | import { apiRouter } from '@/routes'; 13 | 14 | config(); 15 | 16 | export class App { 17 | private app: Application; 18 | private sessionMiddleware: any; 19 | 20 | static bootstrap(port: number) { 21 | const app = new App(); 22 | app.listen(port); 23 | } 24 | 25 | private constructor() { 26 | connect(); 27 | 28 | this.app = express(); 29 | this.sessionMiddleware = session({ 30 | resave: false, 31 | saveUninitialized: false, 32 | secret: process.env.COOKIE_SECRET, 33 | cookie: { 34 | httpOnly: true, 35 | secure: false, 36 | }, 37 | }); 38 | 39 | this.initMiddlewares(); 40 | this.initRouters(); 41 | this.initErrorHandler(); 42 | } 43 | 44 | private initMiddlewares() { 45 | this.app.use(logger('dev')); 46 | this.app.use(express.json()); 47 | this.app.use(express.urlencoded({ extended: false })); 48 | this.app.use(cookieParser(process.env.COOKIE_SECRET)); 49 | this.app.use(this.sessionMiddleware); 50 | this.app.use(flash()); 51 | } 52 | 53 | private initRouters() { 54 | this.app.use('/api', apiRouter); 55 | } 56 | 57 | private initErrorHandler() { 58 | this.app.use((req, res, next) => 59 | next(createHttpError(StatusCode.NOT_FOUND, ErrorMessage.NOT_FOUND)), 60 | ); 61 | 62 | this.app.use( 63 | (err: HttpError, req: Request, res: Response, next: NextFunction) => { 64 | const { status, message } = err; 65 | res.status(status).json({ error: 1, message }); 66 | }, 67 | ); 68 | } 69 | 70 | private listen(port: number): void { 71 | const server = this.app 72 | .listen(port, () => console.log(`Express server listening at ${port}`)) 73 | .on('error', (err) => console.error(err)); 74 | webSocket(server, this.app, this.sessionMiddleware); 75 | } 76 | } 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /backend/src/aops/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import createHttpError from 'http-errors'; 3 | 4 | export enum StatusCode { 5 | OK = 200, 6 | CREATED = 201, 7 | NO_CONTENT = 204, 8 | BAD_REQUEST = 400, 9 | UNAUTHORIZED = 401, 10 | FORBIDDEN = 403, 11 | NOT_FOUND = 404, 12 | CONFLICT = 409, 13 | INTERNAL_SERVER_ERROR = 500, 14 | } 15 | 16 | export enum ErrorMessage { 17 | BAD_REQUEST = 'Bad request', 18 | NOT_FOUND = 'Not found', 19 | } 20 | 21 | export const errorHandler = (controller: any): any => async ( 22 | req: Request, 23 | res: Response, 24 | next: NextFunction, 25 | ) => { 26 | try { 27 | await controller(req, res, next); 28 | } catch (err) { 29 | console.error(err); 30 | const status = StatusCode[err.message]; 31 | next( 32 | createHttpError(status || StatusCode.INTERNAL_SERVER_ERROR, err.message), 33 | ); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /backend/src/aops/index.ts: -------------------------------------------------------------------------------- 1 | export { errorHandler, StatusCode, ErrorMessage } from './errorHandler'; 2 | export { transactionHandler } from './transactionHandler'; 3 | -------------------------------------------------------------------------------- /backend/src/aops/transactionHandler.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | export const transactionHandler = ( 5 | controllerFunc: ( 6 | req: Request, 7 | res: Response, 8 | next?: NextFunction, 9 | ) => Promise, 10 | ) => async ( 11 | req: Request, 12 | res: Response, 13 | next?: NextFunction, 14 | ): Promise => { 15 | const session = await mongoose.startSession(); 16 | session.startTransaction(); 17 | try { 18 | await controllerFunc(req, res, next); 19 | await session.commitTransaction(); 20 | session.endSession(); 21 | } catch (err) { 22 | await session.abortTransaction(); 23 | session.endSession(); 24 | throw err; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /backend/src/controllers/block.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { blockService } from '@/services'; 4 | import { StatusCode, transactionHandler } from '@/aops'; 5 | import { BlockDoc } from '@/models'; 6 | 7 | export const readAll = async (req: Request, res: Response): Promise => { 8 | const blocks = await blockService.readAll(req.params.pageId); 9 | const blockMap = blocks.reduce( 10 | (map: { [blockId: string]: BlockDoc }, block) => { 11 | map[block.id] = block; 12 | return map; 13 | }, 14 | {}, 15 | ); 16 | (req.session as any).pageId = req.params.pageId; 17 | res.status(StatusCode.OK).json({ blockMap }); 18 | }; 19 | 20 | export const publish = async (req: Request, res: Response) => { 21 | const blocks = await blockService.readAll((req.session as any).pageId); 22 | const blockMap = blocks.reduce( 23 | (map: { [blockId: string]: BlockDoc }, block) => { 24 | map[block.id] = block; 25 | return map; 26 | }, 27 | {}, 28 | ); 29 | req.app 30 | .get('io') 31 | .of('/page') 32 | .to((req.session as any).pageId) 33 | .emit('PageUpdate', blockMap); 34 | res.status(StatusCode.OK).json(res.locals.result); 35 | }; 36 | -------------------------------------------------------------------------------- /backend/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * as pageController from './page'; 2 | export * as blockController from './block'; 3 | -------------------------------------------------------------------------------- /backend/src/controllers/page.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { pageService } from '@/services'; 4 | import { StatusCode, transactionHandler } from '@/aops'; 5 | 6 | export const create = transactionHandler( 7 | async (req: Request, res: Response): Promise => { 8 | const page = await pageService.create(req.body.page); 9 | const pages = await pageService.readAll(); 10 | req.app 11 | .get('io') 12 | .of('/pageList') 13 | .emit('PageListUpdate', { 14 | currentPageId: (req.session as any).pageId, 15 | pages, 16 | }); 17 | res.status(StatusCode.CREATED).json({ page, pages }); 18 | }, 19 | ); 20 | 21 | export const readOne = async (req: Request, res: Response): Promise => { 22 | const page = await pageService.readOne(req.params.pageId); 23 | res.status(StatusCode.OK).json({ page }); 24 | }; 25 | 26 | export const readAll = async (req: Request, res: Response): Promise => { 27 | const pages = await pageService.readAll(); 28 | res.status(StatusCode.OK).json({ pages }); 29 | }; 30 | 31 | export const update = async (req: Request, res: Response): Promise => { 32 | const page = await pageService.update(req.params.pageId, req.body.page); 33 | const pages = await pageService.readAll(); 34 | req.app 35 | .get('io') 36 | .of('/pageList') 37 | .emit('PageListUpdate', { 38 | currentPageId: (req.session as any).pageId, 39 | pages, 40 | }); 41 | res.status(StatusCode.OK).json({ page }); 42 | }; 43 | 44 | export const deleteOne = transactionHandler( 45 | async (req: Request, res: Response): Promise => { 46 | await pageService.deleteOne(req.params.pageId); 47 | const pages = await pageService.readAll(); 48 | req.app 49 | .get('io') 50 | .of('/pageList') 51 | .emit('PageListUpdate', { 52 | currentPageId: (req.session as any).pageId, 53 | pages, 54 | }); 55 | res.status(StatusCode.OK).json({ pages }); 56 | }, 57 | ); 58 | -------------------------------------------------------------------------------- /backend/src/middlewares/block.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | import { blockService } from '@/services'; 4 | import { transactionHandler } from '@/aops'; 5 | import { BlockDoc } from '@/models'; 6 | 7 | export const create = transactionHandler( 8 | async (req: Request, res: Response, next: NextFunction): Promise => { 9 | const { parent, block } = await blockService.create({ 10 | parentId: req.params.parentId, 11 | index: req.body.index, 12 | blockDTO: req.body.block, 13 | }); 14 | 15 | res.locals.result = { parent, block }; 16 | next(); 17 | }, 18 | ); 19 | 20 | export const update = async ( 21 | req: Request, 22 | res: Response, 23 | next: NextFunction, 24 | ): Promise => { 25 | const block = await blockService.update(req.params.blockId, req.body.block); 26 | res.locals.result = { block }; 27 | next(); 28 | }; 29 | 30 | export const move = transactionHandler( 31 | async (req: Request, res: Response, next: NextFunction): Promise => { 32 | const { block, from, to } = await blockService.move( 33 | req.params.blockId, 34 | req.params.toId, 35 | req.body.index, 36 | ); 37 | res.locals.result = { block, from, to }; 38 | next(); 39 | }, 40 | ); 41 | 42 | export const deleteCascade = transactionHandler( 43 | async (req: Request, res: Response, next: NextFunction): Promise => { 44 | const parent = await blockService.deleteCascade(req.params.blockId); 45 | res.locals.result = { parent }; 46 | next(); 47 | }, 48 | ); 49 | 50 | export const createAndUpdate = transactionHandler( 51 | async (req: Request, res: Response, next: NextFunction): Promise => { 52 | const { create, update } = req.body; 53 | const { parent, block } = await blockService.create(create); 54 | let updated: BlockDoc = null; 55 | if (update) { 56 | updated = await blockService.update(update.blockId, update); 57 | } 58 | res.locals.result = { parent, block, updated }; 59 | next(); 60 | }, 61 | ); 62 | 63 | export const deleteAndUpdate = transactionHandler( 64 | async (req: Request, res: Response, next: NextFunction): Promise => { 65 | const { deleteId, update } = req.body; 66 | const parent = await blockService.deleteOnly(deleteId); 67 | let updated: BlockDoc = null; 68 | if (update) { 69 | updated = await blockService.update(update.blockId, update); 70 | } 71 | res.locals.result = { parent, updated }; 72 | next(); 73 | }, 74 | ); 75 | -------------------------------------------------------------------------------- /backend/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { objectIdValidator } from './objectIdValidator'; 2 | export * as blockMiddleware from './block'; 3 | -------------------------------------------------------------------------------- /backend/src/middlewares/objectIdValidator.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { Types } from 'mongoose'; 3 | import { ErrorMessage, StatusCode } from '@/aops'; 4 | import createHttpError from 'http-errors'; 5 | 6 | export const objectIdValidator = (...params: string[]) => ( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction, 10 | ): void => { 11 | if (params.some((param) => !Types.ObjectId.isValid(req.params?.[param]))) { 12 | next(createHttpError(StatusCode.BAD_REQUEST, ErrorMessage.BAD_REQUEST)); 13 | return; 14 | } 15 | next(); 16 | }; 17 | -------------------------------------------------------------------------------- /backend/src/models/block.ts: -------------------------------------------------------------------------------- 1 | import { Document, Model, model, Schema, Types } from 'mongoose'; 2 | 3 | export enum BlockType { 4 | PAGE = 'page', 5 | TEXT = 'text', 6 | GRID = 'grid', 7 | COLUMN = 'column', 8 | HEADING1 = 'heading1', 9 | HEADING2 = 'heading2', 10 | HEADING3 = 'heading3', 11 | BULLETED_LIST = 'bulletedlist', 12 | NUMBERED_LIST = 'numberedlist', 13 | TOGGLE_LIST = 'togglelist', 14 | QUOTE = 'quote', 15 | } 16 | 17 | export interface BlockDTO { 18 | _id?: string; 19 | id?: string; 20 | type?: BlockType; 21 | value?: string; 22 | pageId?: string; 23 | parentId?: string; 24 | childIdList?: string[]; 25 | } 26 | 27 | const MODEL_NAME = 'Block'; 28 | const BlockSchema = new Schema( 29 | { 30 | type: { 31 | type: String, 32 | enum: Object.values(BlockType), 33 | default: BlockType.TEXT, 34 | }, 35 | value: { 36 | type: String, 37 | default: '', 38 | }, 39 | pageId: { 40 | type: Schema.Types.ObjectId, 41 | ref: 'Page', 42 | required: true, 43 | }, 44 | parentId: { 45 | type: Schema.Types.ObjectId, 46 | ref: MODEL_NAME, 47 | default: null, 48 | }, 49 | childIdList: [ 50 | { 51 | type: Schema.Types.ObjectId, 52 | ref: MODEL_NAME, 53 | }, 54 | ], 55 | }, 56 | { timestamps: true }, 57 | ); 58 | 59 | BlockSchema.virtual('id').get(function (this: BlockDoc) { 60 | return this._id.toHexString(); 61 | }); 62 | 63 | BlockSchema.set('toJSON', { virtuals: true }); 64 | 65 | export interface BlockModel extends Model { 66 | createOne?: (this: BlockModel, blockDTO: BlockDTO) => Promise; 67 | readOne?: (this: BlockModel, blockId: string) => Promise; 68 | readAll?: (this: BlockModel, pageId: string) => Promise; 69 | updateOneBlock?: ( 70 | this: BlockModel, 71 | blockId: string, 72 | blockDTO: BlockDTO, 73 | ) => Promise; 74 | deleteOneBlock?: (this: BlockModel, blockId: string) => Promise; 75 | } 76 | 77 | export interface BlockDoc extends Document { 78 | type?: BlockType; 79 | value?: string; 80 | pageId?: Types.ObjectId; 81 | parentId?: Types.ObjectId; 82 | childIdList?: Types.ObjectId[]; 83 | 84 | setChild?: (this: BlockDoc, child: BlockDoc, index?: number) => Promise; 85 | setChildren?: ( 86 | this: BlockDoc, 87 | childIdList: Types.ObjectId[], 88 | index?: number, 89 | ) => Promise; 90 | deleteChild?: (this: BlockDoc, childId: string) => Promise; 91 | deleteCascade?: (this: BlockDoc) => Promise; 92 | findIndexFromChildIdList?: (this: BlockDoc, childId: string) => number; 93 | } 94 | 95 | BlockSchema.statics.createOne = async function ( 96 | this: BlockModel, 97 | blockDTO: BlockDTO, 98 | ): Promise { 99 | const block = new this(blockDTO); 100 | await block.save(); 101 | return block; 102 | }; 103 | 104 | BlockSchema.statics.readOne = async function ( 105 | this: BlockModel, 106 | blockId: string, 107 | ): Promise { 108 | return this.findById(blockId).exec(); 109 | }; 110 | 111 | BlockSchema.statics.readAll = async function ( 112 | this: BlockModel, 113 | pageId: string, 114 | ): Promise { 115 | return this.find({ pageId: { $eq: pageId } }).exec(); 116 | }; 117 | 118 | BlockSchema.statics.updateOneBlock = async function ( 119 | this: BlockModel, 120 | blockId: string, 121 | blockDTO: BlockDTO, 122 | ): Promise { 123 | const { type, value } = blockDTO; 124 | return this.findByIdAndUpdate(blockId, { type, value }, { new: true }).exec(); 125 | }; 126 | 127 | BlockSchema.statics.deleteOneBlock = async function ( 128 | this: BlockModel, 129 | blockId: string, 130 | ): Promise { 131 | return this.findByIdAndDelete(blockId).exec(); 132 | }; 133 | 134 | BlockSchema.methods.setChild = async function ( 135 | this: BlockDoc, 136 | child: BlockDoc, 137 | index?: number, 138 | ): Promise { 139 | child.parentId = this.id; 140 | child.pageId = this.pageId; 141 | await child.save(); 142 | 143 | this.childIdList.splice(index ?? this.childIdList.length, 0, child.id); 144 | await this.save(); 145 | }; 146 | 147 | BlockSchema.methods.setChildren = async function ( 148 | this: BlockDoc, 149 | childIdList: Types.ObjectId[], 150 | index?: number, 151 | ): Promise { 152 | const $in = childIdList.map((childId) => childId.toHexString()); 153 | await Block.update( 154 | { _id: { $in } }, 155 | { $set: { parentId: this.id, pageId: this.parentId } }, 156 | { multi: true }, 157 | ).exec(); 158 | 159 | await this.childIdList.splice( 160 | index ?? this.childIdList.length, 161 | 0, 162 | ...childIdList, 163 | ); 164 | await this.save(); 165 | }; 166 | 167 | BlockSchema.methods.deleteChild = async function ( 168 | this: BlockDoc, 169 | childId: string, 170 | ): Promise { 171 | const index = this.findIndexFromChildIdList(childId); 172 | this.childIdList.splice(index, 1); 173 | await this.save(); 174 | }; 175 | 176 | BlockSchema.methods.deleteCascade = async function ( 177 | this: BlockDoc, 178 | ): Promise { 179 | for (const childId of this.childIdList) { 180 | const child = await Block.readOne(childId.toHexString()); 181 | await child.deleteCascade(); 182 | } 183 | 184 | await Block.findByIdAndDelete(this.id).exec(); 185 | }; 186 | 187 | BlockSchema.methods.findIndexFromChildIdList = function ( 188 | this: BlockDoc, 189 | childId: string, 190 | ): number { 191 | return this.childIdList.findIndex( 192 | (_childId) => _childId.toHexString() === childId, 193 | ); 194 | }; 195 | 196 | export const Block = model(MODEL_NAME, BlockSchema) as BlockModel; 197 | -------------------------------------------------------------------------------- /backend/src/models/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { ConnectionOptions } from 'mongoose'; 2 | import { Block } from '@models/block'; 3 | 4 | export const connect = async (dbName?: string): Promise => { 5 | const { MONGO_USERNAME, MONGO_PASSWORD, MONGO_DATABASE } = process.env; 6 | let ip = 'mongo'; 7 | 8 | if (process.env.NODE_ENV !== 'production') { 9 | mongoose.set('debug', true); 10 | ip = 'localhost'; 11 | } 12 | 13 | const url = `mongodb://${ip}:27017?authSource=admin`; 14 | const options: ConnectionOptions = { 15 | user: MONGO_USERNAME, 16 | pass: MONGO_PASSWORD, 17 | dbName: dbName ?? MONGO_DATABASE, 18 | useNewUrlParser: true, 19 | useUnifiedTopology: true, 20 | reconnectTries: Number.MAX_VALUE, 21 | reconnectInterval: 500, 22 | connectTimeoutMS: 10000, 23 | }; 24 | 25 | try { 26 | await mongoose.connect(url, options); 27 | console.log('MongoDB is connected'); 28 | } catch (e) { 29 | console.error('MongoDB connection failed by', e); 30 | } 31 | }; 32 | 33 | export type { PageDTO, PageDoc } from './page'; 34 | export { Page } from './page'; 35 | export type { BlockDTO, BlockDoc } from './block'; 36 | export { Block, BlockType } from './block'; 37 | -------------------------------------------------------------------------------- /backend/src/models/page.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema, Types, model, Model } from 'mongoose'; 2 | 3 | import { Block, BlockType } from '.'; 4 | 5 | export interface PageDTO { 6 | _id?: string; 7 | id?: string; 8 | title?: string; 9 | rootId?: string; 10 | } 11 | 12 | const PageSchema = new Schema( 13 | { 14 | title: { 15 | type: String, 16 | default: '', 17 | }, 18 | rootId: { 19 | type: Schema.Types.ObjectId, 20 | ref: 'Block', 21 | default: null, 22 | }, 23 | }, 24 | { timestamps: true }, 25 | ); 26 | 27 | PageSchema.virtual('id').get(function (this: PageDoc) { 28 | return this._id.toHexString(); 29 | }); 30 | 31 | PageSchema.set('toJSON', { virtuals: true }); 32 | 33 | export interface PageModel extends Model { 34 | createOne?: (this: PageModel, pageDTO?: PageDTO) => Promise; 35 | readOne?: (this: PageModel, pageId: string) => Promise; 36 | readAll?: (this: PageModel) => Promise; 37 | updateOnePage?: ( 38 | this: PageModel, 39 | pageId: string, 40 | pageDTO: PageDTO, 41 | ) => Promise; 42 | } 43 | 44 | export interface PageDoc extends Document { 45 | title?: string; 46 | rootId?: Types.ObjectId; 47 | createdAt?: Date; 48 | 49 | delete?: (this: PageDoc) => Promise; 50 | } 51 | 52 | PageSchema.statics.createOne = async function ( 53 | this: PageModel, 54 | pageDTO?: PageDTO, 55 | ): Promise { 56 | const page = new this(pageDTO ?? {}); 57 | await page.save(); 58 | const block = await Block.createOne({ 59 | type: BlockType.PAGE, 60 | pageId: page.id, 61 | }); 62 | page.rootId = block.id; 63 | await page.save(); 64 | return page; 65 | }; 66 | 67 | PageSchema.statics.readOne = async function ( 68 | this: PageModel, 69 | pageId: string, 70 | ): Promise { 71 | return this.findById(pageId).exec(); 72 | }; 73 | 74 | PageSchema.statics.readAll = async function ( 75 | this: PageModel, 76 | ): Promise { 77 | return this.find() 78 | .sort([['createdAt', -1]]) 79 | .exec(); 80 | }; 81 | 82 | PageSchema.statics.updateOnePage = async function ( 83 | this: PageModel, 84 | pageId: string, 85 | pageDTO: PageDTO, 86 | ): Promise { 87 | const { title } = pageDTO; 88 | return this.findByIdAndUpdate(pageId, { title }, { new: true }).exec(); 89 | }; 90 | 91 | PageSchema.methods.delete = async function (this: PageDoc): Promise { 92 | await Block.deleteMany({ pageId: { $eq: this.id } }).exec(); 93 | await Page.findByIdAndDelete(this.id).exec(); 94 | }; 95 | 96 | export const Page = model('Page', PageSchema) as PageModel; 97 | -------------------------------------------------------------------------------- /backend/src/routes/block.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { blockMiddleware } from '@/middlewares'; 4 | import { blockController } from '@/controllers'; 5 | import { errorHandler } from '@/aops'; 6 | import { objectIdValidator } from '@/middlewares'; 7 | 8 | export const blockRouter = Router(); 9 | 10 | blockRouter.post( 11 | '/parent-id/:parentId', 12 | objectIdValidator('parentId'), 13 | errorHandler(blockMiddleware.create), 14 | blockController.publish, 15 | ); 16 | blockRouter.get( 17 | '/page-id/:pageId', 18 | objectIdValidator('pageId'), 19 | errorHandler(blockController.readAll), 20 | ); 21 | blockRouter.patch( 22 | '/id/:blockId', 23 | objectIdValidator('blockId'), 24 | errorHandler(blockMiddleware.update), 25 | blockController.publish, 26 | ); 27 | blockRouter.patch( 28 | '/id/:blockId/to/:toId', 29 | objectIdValidator('blockId', 'toId'), 30 | errorHandler(blockMiddleware.move), 31 | blockController.publish, 32 | ); 33 | blockRouter.delete( 34 | '/id/:blockId', 35 | objectIdValidator('blockId'), 36 | errorHandler(blockMiddleware.deleteCascade), 37 | blockController.publish, 38 | ); 39 | blockRouter.patch( 40 | '/create-and-update', 41 | errorHandler(blockMiddleware.createAndUpdate), 42 | blockController.publish, 43 | ); 44 | blockRouter.patch( 45 | '/delete-and-update', 46 | errorHandler(blockMiddleware.deleteAndUpdate), 47 | blockController.publish, 48 | ); 49 | -------------------------------------------------------------------------------- /backend/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { pageRouter } from '@routes/page'; 4 | import { blockRouter } from '@routes/block'; 5 | 6 | export const apiRouter = Router(); 7 | 8 | apiRouter.use('/pages', pageRouter); 9 | apiRouter.use('/blocks', blockRouter); 10 | -------------------------------------------------------------------------------- /backend/src/routes/page.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { objectIdValidator } from '@/middlewares'; 4 | import { errorHandler } from '@/aops'; 5 | import { pageController } from '@/controllers'; 6 | 7 | export const pageRouter = Router(); 8 | 9 | pageRouter.post('', errorHandler(pageController.create)); 10 | pageRouter.get( 11 | '/id/:pageId', 12 | objectIdValidator('pageId'), 13 | errorHandler(pageController.readOne), 14 | ); 15 | pageRouter.get('', errorHandler(pageController.readAll)); 16 | pageRouter.patch( 17 | '/id/:pageId', 18 | objectIdValidator('pageId'), 19 | errorHandler(pageController.update), 20 | ); 21 | pageRouter.delete( 22 | '/id/:pageId', 23 | objectIdValidator('pageId'), 24 | errorHandler(pageController.deleteOne), 25 | ); 26 | -------------------------------------------------------------------------------- /backend/src/services/block.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockDoc, BlockDTO } from '@/models'; 2 | import { ErrorMessage } from '@/aops'; 3 | 4 | export const create = async (param: { 5 | parentId: string; 6 | index?: number; 7 | blockDTO?: BlockDTO; 8 | }): Promise<{ parent: BlockDoc; block: BlockDoc }> => { 9 | const parent = await Block.readOne(param.parentId); 10 | if (!parent) { 11 | throw new Error(ErrorMessage.NOT_FOUND); 12 | } 13 | 14 | const block = await Block.createOne({ 15 | ...param.blockDTO, 16 | pageId: parent.pageId.toHexString(), 17 | }); 18 | await parent.setChild(block, param.index); 19 | return { parent, block }; 20 | }; 21 | 22 | export const readAll = async (pageId: string): Promise => { 23 | return Block.readAll(pageId); 24 | }; 25 | 26 | export const update = async ( 27 | blockId: string, 28 | blockDTO: BlockDTO, 29 | ): Promise => { 30 | const block = await Block.updateOneBlock(blockId, blockDTO); 31 | if (!block) { 32 | throw new Error(ErrorMessage.NOT_FOUND); 33 | } 34 | return block; 35 | }; 36 | 37 | export const move = async ( 38 | blockId: string, 39 | toId: string, 40 | toIndex?: number, 41 | ): Promise<{ block: BlockDoc; from?: BlockDoc; to?: BlockDoc }> => { 42 | const block = await Block.readOne(blockId); 43 | const from = await Block.readOne(block.parentId.toHexString()); 44 | const to = await Block.readOne(toId); 45 | 46 | if (from.id !== to.id) { 47 | await from.deleteChild(blockId); 48 | await to.setChild(block, toIndex); 49 | return { block, from, to }; 50 | } 51 | 52 | toIndex ??= to.childIdList.length; 53 | const fromIndex = from.findIndexFromChildIdList(blockId); 54 | toIndex = fromIndex < toIndex ? toIndex - 1 : toIndex; 55 | await to.deleteChild(blockId); 56 | await to.setChild(block, toIndex); 57 | return { block, from: null, to }; 58 | }; 59 | 60 | export const deleteCascade = async (blockId: string): Promise => { 61 | const block = await Block.readOne(blockId); 62 | if (!block) { 63 | throw new Error(ErrorMessage.NOT_FOUND); 64 | } 65 | 66 | const parent = await Block.readOne(block.parentId.toHexString()); 67 | await block.deleteCascade(); 68 | await parent.deleteChild(blockId); 69 | return parent; 70 | }; 71 | 72 | export const deleteOnly = async (blockId: string): Promise => { 73 | const block = await Block.readOne(blockId); 74 | if (!block) { 75 | throw new Error(ErrorMessage.NOT_FOUND); 76 | } 77 | 78 | const parent = await Block.readOne(block.parentId.toHexString()); 79 | const index = parent.findIndexFromChildIdList(blockId); 80 | 81 | await parent.setChildren(block.childIdList, index); 82 | await parent.deleteChild(blockId); 83 | await Block.deleteOneBlock(blockId); 84 | return parent; 85 | }; 86 | -------------------------------------------------------------------------------- /backend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * as pageService from './page'; 2 | export * as blockService from './block'; 3 | -------------------------------------------------------------------------------- /backend/src/services/page.ts: -------------------------------------------------------------------------------- 1 | import { Page, PageDoc, PageDTO } from '@/models'; 2 | import { ErrorMessage } from '@/aops'; 3 | 4 | export const create = async (pageDTO?: PageDTO): Promise => { 5 | return Page.createOne(pageDTO); 6 | }; 7 | 8 | export const readOne = async (pageId: string): Promise => { 9 | const page = await Page.readOne(pageId); 10 | if (!page) { 11 | throw new Error(ErrorMessage.NOT_FOUND); 12 | } 13 | return page; 14 | }; 15 | 16 | export const readAll = async (): Promise => { 17 | return Page.readAll(); 18 | }; 19 | 20 | export const update = async ( 21 | pageId: string, 22 | pageDTO: PageDTO, 23 | ): Promise => { 24 | if (!pageDTO?.title) { 25 | pageDTO.title = ''; 26 | } 27 | 28 | const page = await Page.updateOnePage(pageId, pageDTO); 29 | if (!page) { 30 | throw new Error(ErrorMessage.NOT_FOUND); 31 | } 32 | return page; 33 | }; 34 | 35 | export const deleteOne = async (pageId: string): Promise => { 36 | const page = await Page.readOne(pageId); 37 | if (!page) { 38 | throw new Error(ErrorMessage.NOT_FOUND); 39 | } 40 | 41 | const pageDTO: PageDTO = page.toJSON(); 42 | await page.delete(); 43 | return pageDTO; 44 | }; 45 | -------------------------------------------------------------------------------- /backend/src/socket.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express'; 2 | import http from 'http'; 3 | import { Server, Socket } from 'socket.io'; 4 | 5 | const webSocket = (server: http.Server, app: Application, session: any) => { 6 | const io = new Server(server, { path: '/socket.io' }); 7 | 8 | app.set('io', io); 9 | const page = io.of('/page'); 10 | const pageList = io.of('/pageList'); 11 | 12 | io.use((socket, next) => { 13 | session(socket.request, {}, next); 14 | }); 15 | 16 | pageList.on('connection', (socket: Socket) => { 17 | const req = socket.request; 18 | const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 19 | pageList.emit('allUserCount', pageList.sockets.size); 20 | socket.on('disconnect', () => { 21 | pageList.emit('allUserCount', pageList.sockets.size); 22 | }); 23 | }); 24 | 25 | page.on('connection', (socket: Socket) => { 26 | const req = socket.request as any; 27 | const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 28 | const leaveBeforeRoom = (socket: Socket) => { 29 | socket.rooms.forEach((room) => { 30 | socket.leave(room); 31 | const clientsSize = (page.adapter as any).rooms.get(room)?.size; 32 | page.to(room).emit('pageUserCount', clientsSize ?? 0); 33 | }); 34 | }; 35 | leaveBeforeRoom(socket); 36 | socket.on('disconnect', () => { 37 | leaveBeforeRoom(socket); 38 | }); 39 | 40 | socket.on('error', (error) => { 41 | console.error(error); 42 | leaveBeforeRoom(socket); 43 | }); 44 | 45 | socket.on('join', (pageId) => { 46 | leaveBeforeRoom(socket); 47 | socket.join(pageId); 48 | page 49 | .to(pageId) 50 | .emit('pageUserCount', (page.adapter as any).rooms.get(pageId).size); 51 | }); 52 | }); 53 | }; 54 | 55 | export default webSocket; 56 | -------------------------------------------------------------------------------- /backend/src/utils/generateId.ts: -------------------------------------------------------------------------------- 1 | const hexadecimals = '0123456789abcdef'; 2 | 3 | export const generateId = () => { 4 | const timestamp = Date.now() + ''; 5 | return ( 6 | timestamp + 7 | new Array(24 - timestamp.length) 8 | .fill(null) 9 | .map((_) => hexadecimals[(Math.random() * hexadecimals.length) | 0]) 10 | .join('') 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /backend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { generateId } from './generateId'; 2 | -------------------------------------------------------------------------------- /backend/src/www.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register'; 2 | import { config } from 'dotenv'; 3 | 4 | import App from './App'; 5 | 6 | config(); 7 | 8 | const port: number = Number(process.env.BACKEND_PORT) || 3000; 9 | App.bootstrap(port); 10 | -------------------------------------------------------------------------------- /backend/test/services/block.spec.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import * as mongoose from 'mongoose'; 3 | 4 | import { Block, BlockDTO, BlockType, connect, PageDoc } from '@/models'; 5 | import { blockService, pageService } from '@/services'; 6 | 7 | config(); 8 | 9 | describe('@services/block', () => { 10 | const dbName = 'bootionTest'; 11 | let conn: mongoose.Connection; 12 | let page: PageDoc; 13 | 14 | beforeAll(async () => { 15 | await connect(dbName); 16 | conn = mongoose.connections[0]; 17 | }); 18 | 19 | beforeEach(async () => { 20 | page = await pageService.create(); 21 | }); 22 | 23 | afterEach(async () => { 24 | await conn.dropDatabase(); 25 | }); 26 | 27 | afterAll(async () => { 28 | try { 29 | await conn.dropDatabase(); 30 | await conn.close(); 31 | console.log('MongoDB is disconnected'); 32 | } catch (e) { 33 | console.error('MongoDB disconnection failed by', e); 34 | } 35 | }); 36 | 37 | it('create: Success', async () => { 38 | const { block, parent } = await blockService.create({ 39 | parentId: page.rootId.toHexString(), 40 | }); 41 | 42 | expect(block.parentId.toHexString()).toEqual(parent.id); 43 | expect( 44 | parent.childIdList.some((childId) => childId.toHexString() === block.id), 45 | ).toBeTruthy(); 46 | }); 47 | 48 | it('create: Success param', async () => { 49 | const { block } = await blockService.create({ 50 | parentId: page.rootId.toHexString(), 51 | }); 52 | const index = 0; 53 | const expected: BlockDTO = { value: 'expected', type: BlockType.HEADING1 }; 54 | const { block: received, parent } = await blockService.create({ 55 | parentId: page.rootId.toHexString(), 56 | blockDTO: expected, 57 | index, 58 | }); 59 | 60 | expect(received.value).toEqual(expected.value); 61 | expect(received.type).toEqual(expected.type); 62 | expect(parent.findIndexFromChildIdList(received.id)).toEqual(index); 63 | }); 64 | 65 | it('create: Duplicated error', async () => { 66 | const { block } = await blockService.create({ 67 | parentId: page.rootId.toHexString(), 68 | }); 69 | 70 | await expect(async () => 71 | blockService.create({ 72 | parentId: page.rootId.toHexString(), 73 | blockDTO: block.toJSON(), 74 | }), 75 | ).rejects.toThrow(); 76 | }); 77 | 78 | it('create: Not found', async () => { 79 | const params = { parentId: 'invalid id' }; 80 | 81 | await expect(async () => blockService.create(params)).rejects.toThrow(); 82 | }); 83 | 84 | it('readAll: Success', async () => { 85 | const param = { parentId: page.rootId.toHexString() }; 86 | const { block: block01 } = await blockService.create(param); 87 | const { block: block02, parent } = await blockService.create(param); 88 | const expected = [parent, block01, block02].map((blockDoc) => 89 | blockDoc.toJSON(), 90 | ); 91 | const received = (await blockService.readAll(page.id)).map((pageDoc) => 92 | pageDoc.toJSON(), 93 | ); 94 | 95 | expect(received).toEqual(expected); 96 | }); 97 | 98 | it('update: Success', async () => { 99 | const { block } = await blockService.create({ 100 | parentId: page.rootId.toHexString(), 101 | }); 102 | const expected = { 103 | ...block.toJSON(), 104 | value: 'updated', 105 | type: BlockType.HEADING1, 106 | }; 107 | const received = await blockService.update(block.id, expected); 108 | 109 | expect(received.value).toEqual(expected.value); 110 | expect(received.type).toEqual(expected.type); 111 | }); 112 | 113 | it('update: Not found', async () => { 114 | const received = { blockId: 'invalid id', blockDTO: {} }; 115 | 116 | await expect(async () => 117 | blockService.update(received.blockId, received.blockDTO), 118 | ).rejects.toThrow(); 119 | }); 120 | 121 | it('move: Success moving one to another', async () => { 122 | const param = { parentId: page.rootId.toHexString() }; 123 | const { block: block01 } = await blockService.create(param); 124 | const { block: block02, parent } = await blockService.create(param); 125 | 126 | const { block: received, from, to } = await blockService.move( 127 | block01.id, 128 | block02.id, 129 | ); 130 | 131 | expect(received.parentId.toHexString()).toEqual(block02.id); 132 | expect(from.id).toEqual(parent.id); 133 | expect(to.id).toEqual(block02.id); 134 | expect( 135 | from.childIdList.every( 136 | (childId) => childId.toHexString() !== received.id, 137 | ), 138 | ).toBeTruthy(); 139 | expect( 140 | to.childIdList.some((childId) => childId.toHexString() === received.id), 141 | ).toBeTruthy(); 142 | }); 143 | 144 | it('move: Success changing order', async () => { 145 | const param = { parentId: page.rootId.toHexString() }; 146 | const { block: block01 } = await blockService.create(param); 147 | const { block: block02, parent } = await blockService.create(param); 148 | 149 | const { to } = await blockService.move(block02.id, parent.id, 0); 150 | const received = to.childIdList.map((childId) => childId.toHexString()); 151 | 152 | expect(to.id).toEqual(parent.id); 153 | expect(received).toEqual([block02.id, block01.id]); 154 | }); 155 | 156 | it('deleteCascade: Success', async () => { 157 | const { block } = await blockService.create({ 158 | parentId: page.rootId.toHexString(), 159 | }); 160 | const { block: child } = await blockService.create({ 161 | parentId: block.id, 162 | }); 163 | const blockId = block.id; 164 | const childId = child.id; 165 | 166 | const parent = await blockService.deleteCascade(block.id); 167 | 168 | expect(parent.id).toEqual(block.parentId.toHexString()); 169 | expect( 170 | parent.childIdList.every((childId) => childId.toHexString() !== block.id), 171 | ).toBeTruthy(); 172 | expect(await Block.readOne(blockId)).toBeNull(); 173 | expect(await Block.readOne(childId)).toBeNull(); 174 | }); 175 | 176 | it('deleteCascade: Not found', async () => { 177 | const received: BlockDTO = { id: 'invalid id' }; 178 | 179 | await expect(async () => 180 | blockService.deleteCascade(received.id), 181 | ).rejects.toThrow(); 182 | }); 183 | 184 | it('deleteOnly: Success', async () => { 185 | const { block } = await blockService.create({ 186 | parentId: page.rootId.toHexString(), 187 | }); 188 | const { block: sibling } = await blockService.create({ 189 | parentId: page.rootId.toHexString(), 190 | }); 191 | let { block: child } = await blockService.create({ 192 | parentId: block.id, 193 | }); 194 | let { block: anotherChild } = await blockService.create({ 195 | parentId: block.id, 196 | }); 197 | 198 | const parent = await blockService.deleteOnly(block.id); 199 | child = await Block.readOne(child.id); 200 | anotherChild = await Block.readOne(anotherChild.id); 201 | const receivedChildIdList = parent.childIdList.map((childId) => 202 | childId.toHexString(), 203 | ); 204 | 205 | expect(await Block.readOne(block.id)).toBeNull(); 206 | expect(parent.id).toEqual(block.parentId.toHexString()); 207 | expect(child.parentId).toEqual(block.parentId); 208 | expect(anotherChild.parentId).toEqual(block.parentId); 209 | expect(receivedChildIdList).toEqual([ 210 | child.id, 211 | anotherChild.id, 212 | sibling.id, 213 | ]); 214 | }); 215 | 216 | it('deleteOnly: Not found', async () => { 217 | const received: BlockDTO = { id: 'invalid id' }; 218 | 219 | await expect(async () => 220 | blockService.deleteOnly(received.id), 221 | ).rejects.toThrow(); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /backend/test/services/page.spec.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import * as mongoose from 'mongoose'; 3 | 4 | import { connect, PageDTO } from '@/models'; 5 | import { pageService } from '@/services'; 6 | import { ErrorMessage } from '@/aops'; 7 | 8 | config(); 9 | 10 | describe('@services/page', () => { 11 | const dbName = 'bootionTest'; 12 | let conn: mongoose.Connection; 13 | 14 | beforeAll(async () => { 15 | await connect(dbName); 16 | conn = mongoose.connections[0]; 17 | }); 18 | 19 | beforeEach(async () => { 20 | // 21 | }); 22 | 23 | afterEach(async () => { 24 | await conn.dropDatabase(); 25 | }); 26 | 27 | afterAll(async () => { 28 | try { 29 | await conn.dropDatabase(); 30 | await conn.close(); 31 | console.log('MongoDB is disconnected'); 32 | } catch (e) { 33 | console.error('MongoDB disconnection failed by', e); 34 | } 35 | }); 36 | 37 | it('create: Success', async () => { 38 | const pageDTO: PageDTO = { title: 'test page title' }; 39 | const received = await pageService.create(pageDTO); 40 | 41 | expect(received?.toJSON().title).toEqual(pageDTO.title); 42 | }); 43 | 44 | it('create: Duplicated error', async () => { 45 | const received = await pageService.create(); 46 | 47 | await expect(async () => 48 | pageService.create(received.toJSON()), 49 | ).rejects.toThrow(); 50 | }); 51 | 52 | it('readOne: Success', async () => { 53 | const expected = await pageService.create(); 54 | const received = await pageService.readOne(expected.id); 55 | 56 | expect(received?.toJSON()).toEqual(expected?.toJSON()); 57 | }); 58 | 59 | it('readOne: Not found', async () => { 60 | const received: PageDTO = { id: 'invalid id' }; 61 | 62 | await expect(async () => 63 | pageService.readOne(received.id), 64 | ).rejects.toThrow(); 65 | }); 66 | 67 | it('readAll: Success', async () => { 68 | const page01 = await pageService.create({ title: 'test page 01' }); 69 | const page02 = await pageService.create({ title: 'test page 02' }); 70 | const expected = [page01, page02] 71 | .map((pageDoc) => pageDoc.toJSON()) 72 | .reverse(); 73 | const received = (await pageService.readAll()).map((pageDoc) => 74 | pageDoc.toJSON(), 75 | ); 76 | 77 | expect(received).toEqual(expected); 78 | }); 79 | 80 | it('update: Success', async () => { 81 | const page = await pageService.create(); 82 | const expected: PageDTO = { ...page.toJSON(), title: 'updated title' }; 83 | const received = await pageService.update(expected.id, expected); 84 | 85 | expect(received?.toJSON().title).toEqual(expected.title); 86 | }); 87 | 88 | it('update: Not found', async () => { 89 | const received: PageDTO = { id: 'invalid id' }; 90 | 91 | await expect( 92 | async () => await pageService.update(received.id, received), 93 | ).rejects.toThrow(); 94 | }); 95 | 96 | it('delete: Success', async () => { 97 | const pageDTO: PageDTO = (await pageService.create()).toJSON(); 98 | const received = await pageService.deleteOne(pageDTO.id); 99 | 100 | await expect( 101 | async () => await pageService.readOne(received.id), 102 | ).rejects.toThrow(); 103 | }); 104 | 105 | it('delete: Not found', async () => { 106 | const received: PageDTO = { id: 'invalid id' }; 107 | 108 | await expect( 109 | async () => await pageService.deleteOne(received.id), 110 | ).rejects.toThrow(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "es5", 6 | "es6" 7 | ], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "outDir": "./build", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "sourceMap": true, 14 | "removeComments": true, 15 | "noImplicitAny": true, 16 | "esModuleInterop": true, 17 | "allowJs": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "strictNullChecks": false, 21 | "forceConsistentCasingInFileNames": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "resolveJsonModule": true, 24 | "isolatedModules": true, 25 | "allowSyntheticDefaultImports": true 26 | }, 27 | "include": [ 28 | "src", 29 | "test" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "config", 34 | "dist" 35 | ], 36 | "extends": "./tsconfig.paths.json" 37 | } 38 | -------------------------------------------------------------------------------- /backend/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"], 6 | "@routes/*": ["src/routes/*"], 7 | "@middlewares/*": ["src/middlewares/*"], 8 | "@aops/*": ["src/aops/*"], 9 | "@controllers/*": ["src/controllers/*"], 10 | "@services/*": ["src/services/*"], 11 | "@models/*": ["src/models/*"], 12 | "@utils/*": ["src/utils/*"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | 5 | mongo: 6 | container_name: mongo 7 | image: mongo 8 | ports: 9 | - "27017:27017" 10 | volumes: 11 | - ./mongo/db:/data/db 12 | - ./mongo/log:/var/log/mongodb/ 13 | environment: 14 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME} 15 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} 16 | MONGO_INITDB_DATABASE: ${MONGO_DATABASE} 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | 5 | mongo: 6 | container_name: mongo 7 | image: mongo 8 | expose: 9 | - "27017" 10 | volumes: 11 | - ./mongo/db:/data/db 12 | - ./mongo/log:/var/log/mongodb/ 13 | restart: always 14 | environment: 15 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME} 16 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} 17 | MONGO_INITDB_DATABASE: ${MONGO_DATABASE} 18 | command: mongod --auth 19 | 20 | backend: 21 | container_name: backend 22 | build: 23 | context: ./backend 24 | ports: 25 | - "${BACKEND_PORT}:3000" 26 | links: 27 | - mongo 28 | depends_on: 29 | - mongo 30 | restart: always 31 | environment: 32 | - NODE_ENV=production 33 | - MONGO_USERNAME=${MONGO_USERNAME} 34 | - MONGO_PASSWORD=${MONGO_PASSWORD} 35 | - MONGO_DATABASE=${MONGO_DATABASE} 36 | - COOKIE_SECRET=${COOKIE_SECRET} 37 | command: 38 | - bash 39 | - -c 40 | - | 41 | /wait-for-it.sh mongo:27017 -t 10 42 | pm2-docker /dist/www.js 43 | 44 | frontend: 45 | container_name: frontend 46 | build: 47 | context: ./frontend 48 | ports: 49 | - "80:80" 50 | restart: always 51 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .pnp.* 118 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | build/* 3 | nginx/* 4 | node_modules/* 5 | public/* 6 | src/react-app-env.d.ts 7 | src/serviceWorker.ts 8 | -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@typescript-eslint", 4 | "prettier" 5 | ], 6 | "extends": [ 7 | "airbnb-typescript", 8 | "react-app", 9 | "prettier", 10 | "prettier/@typescript-eslint" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "./tsconfig.json" 15 | }, 16 | "rules": { 17 | "prettier/prettier": [ 18 | 0, 19 | { 20 | "endOfLine": "auto" 21 | } 22 | ], 23 | "react/jsx-filename-extension": [ 24 | 0, 25 | { 26 | "extensions": [ 27 | ".js", 28 | ".jsx", 29 | ".ts", 30 | ".tsx" 31 | ] 32 | } 33 | ], 34 | "react/jsx-props-no-spreading": 0, 35 | "no-use-before-define": 0, 36 | "@typescript-eslint/no-var-requires": 0, 37 | "import/extensions": 0, 38 | "import/no-unresolved": 0, 39 | "import/prefer-default-export": "off", 40 | "jsx-a11y/no-static-element-interactions": 0, 41 | "@typescript-eslint/indent": 0, 42 | "no-param-reassign": 0 43 | }, 44 | "settings": { 45 | "import/resolver": { 46 | "node": { 47 | "extensions": [ 48 | ".js", 49 | ".jsx", 50 | ".ts", 51 | ".tsx" 52 | ] 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "trailingComma": "all", 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /frontend/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const resolvePathFromRoot = (...pathSegments) => 4 | path.resolve(__dirname, '..', ...pathSegments); 5 | 6 | module.exports = { 7 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 8 | addons: [ 9 | '@storybook/addon-links', 10 | '@storybook/addon-essentials', 11 | '@storybook/addon-actions', 12 | '@storybook/addon-knobs', 13 | '@storybook/preset-create-react-app', 14 | ], 15 | webpackFinal: async (config, { configType }) => { 16 | config.resolve.alias = { 17 | ...config.resolve.alias, 18 | '@': resolvePathFromRoot('src'), 19 | '@assets': resolvePathFromRoot('src', 'assets'), 20 | '@utils': resolvePathFromRoot('src', 'utils'), 21 | '@stores': resolvePathFromRoot('src', 'stores'), 22 | '@components': resolvePathFromRoot('src', 'components'), 23 | '@atoms': resolvePathFromRoot('src', 'components', 'atoms'), 24 | '@molecules': resolvePathFromRoot('src', 'components', 'molecules'), 25 | '@organisms': resolvePathFromRoot('src', 'components', 'organisms'), 26 | '@pages': resolvePathFromRoot('src', 'components', 'pages'), 27 | }; 28 | return config; 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { Global, css } from '@emotion/react'; 2 | import { RecoilRoot } from 'recoil'; 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | }; 7 | 8 | export const decorators = [ 9 | (Story) => ( 10 | 11 |
18 | 30 | 31 |
32 |
33 | ), 34 | ]; 35 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 as build 2 | 3 | COPY package*.json ./ 4 | RUN npm install 5 | 6 | COPY . . 7 | RUN npm run build 8 | 9 | FROM nginx 10 | 11 | COPY --from=build /build /usr/share/nginx/html 12 | COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf 13 | 14 | CMD ["nginx", "-g", "daemon off;"] 15 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | const { alias, configPaths } = require('react-app-rewire-alias'); 2 | 3 | module.exports = function override(config) { 4 | alias(configPaths('./tsconfig.paths.json'))(config); 5 | 6 | return config; 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream api-server { 2 | least_conn; 3 | server backend:3000 weight=10 max_fails=3 fail_timeout=30s; 4 | } 5 | 6 | server { 7 | listen 80; 8 | 9 | location / { 10 | root /usr/share/nginx/html; 11 | index index.html index.htm; 12 | try_files $uri $uri/ /index.html; 13 | } 14 | 15 | location /api { 16 | proxy_pass http://api-server; 17 | proxy_http_version 1.1; 18 | proxy_set_header Upgrade $http_upgrade; 19 | proxy_set_header Connection 'upgrade'; 20 | proxy_set_header Host $host; 21 | proxy_cache_bypass $http_upgrade; 22 | } 23 | 24 | location /socket.io { 25 | proxy_pass http://api-server; 26 | proxy_http_version 1.1; 27 | proxy_set_header Upgrade $http_upgrade; 28 | proxy_set_header Connection 'upgrade'; 29 | proxy_set_header Host $host; 30 | proxy_cache_bypass $http_upgrade; 31 | } 32 | 33 | error_page 500 502 503 504 /50x.html; 34 | 35 | location = /50x.html { 36 | root /usr/share/nginx/html; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@babel/core": "^7.12.7", 7 | "@emotion/css": "^11.0.0", 8 | "@emotion/react": "^11.1.1", 9 | "@emotion/styled": "^11.0.0", 10 | "@material-ui/core": "^4.11.0", 11 | "@storybook/addon-knobs": "^6.1.6", 12 | "@testing-library/jest-dom": "^5.11.6", 13 | "@testing-library/react": "^11.2.2", 14 | "@testing-library/user-event": "^12.2.2", 15 | "@types/jest": "^26.0.15", 16 | "@types/node": "^12.19.6", 17 | "@types/react": "^16.14.1", 18 | "@types/react-dom": "^16.9.10", 19 | "@types/socket.io-client": "^1.4.34", 20 | "axios": "^0.21.0", 21 | "babel-loader": "^8.1.0", 22 | "react": "^17.0.1", 23 | "react-app-rewire-alias": "^0.1.8", 24 | "react-app-rewired": "^2.1.6", 25 | "react-dom": "^17.0.1", 26 | "react-scripts": "^4.0.1", 27 | "react-spring": "^8.0.27", 28 | "recoil": "^0.1.2", 29 | "socket.io-client": "^3.0.4", 30 | "typescript": "^4.1.2", 31 | "web-vitals": "^0.2.4" 32 | }, 33 | "scripts": { 34 | "start": "react-app-rewired start", 35 | "build": "react-app-rewired build", 36 | "test": "react-app-rewired test", 37 | "eject": "react-scripts eject", 38 | "storybook": "start-storybook -p 6006 -s public", 39 | "build-storybook": "build-storybook -s public" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@storybook/addon-actions": "^6.1.6", 61 | "@storybook/addon-docs": "^6.1.6", 62 | "@storybook/addon-essentials": "^6.1.6", 63 | "@storybook/addon-links": "^6.1.6", 64 | "@storybook/node-logger": "^6.1.6", 65 | "@storybook/preset-create-react-app": "^3.1.5", 66 | "@storybook/react": "^6.1.6", 67 | "@typescript-eslint/eslint-plugin": "^4.8.1", 68 | "@typescript-eslint/parser": "^4.8.1", 69 | "babel-plugin-emotion": "^11.0.0", 70 | "eslint": "^7.14.0", 71 | "eslint-config-airbnb": "^18.2.1", 72 | "eslint-config-airbnb-typescript": "^12.0.0", 73 | "eslint-config-prettier": "^6.15.0", 74 | "eslint-config-react-app": "^6.0.0", 75 | "eslint-import-resolver-node": "^0.3.4", 76 | "eslint-import-resolver-typescript": "^2.3.0", 77 | "eslint-loader": "^4.0.2", 78 | "eslint-plugin-flowtype": "^5.2.0", 79 | "eslint-plugin-import": "^2.22.1", 80 | "eslint-plugin-jsx-a11y": "^6.4.1", 81 | "eslint-plugin-prettier": "^3.1.4", 82 | "eslint-plugin-react": "^7.21.5", 83 | "eslint-plugin-react-hooks": "^4.2.0", 84 | "husky": "^4.3.0", 85 | "lint-staged": "^10.5.2", 86 | "prettier": "^2.2.0", 87 | "prettier-eslint": "^12.0.0", 88 | "prettier-eslint-cli": "^5.0.0" 89 | }, 90 | "husky": { 91 | "hooks": { 92 | "pre-commit": "lint-staged" 93 | } 94 | }, 95 | "lint-staged": { 96 | "*.{js,jsx,ts,tsx}": "eslint --fix" 97 | }, 98 | "proxy": "http://localhost:4000" 99 | } 100 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Bootion 28 | 29 | 30 | 31 |
32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime classic */ 2 | /** @jsx jsx */ 3 | import { jsx, css, Global } from '@emotion/react'; 4 | 5 | import { PageComponent } from '@components/pages'; 6 | 7 | function App(): JSX.Element { 8 | return ( 9 |
10 | 22 | 23 |
24 | ); 25 | } 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /frontend/src/assets/bulletedList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/bulletedList.png -------------------------------------------------------------------------------- /frontend/src/assets/check.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/doubleChevronLeft.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/doubleChevronRight.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/draggable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/hamburgerMenu.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/heading1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/heading1.png -------------------------------------------------------------------------------- /frontend/src/assets/heading2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/heading2.png -------------------------------------------------------------------------------- /frontend/src/assets/heading3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/heading3.png -------------------------------------------------------------------------------- /frontend/src/assets/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/assets/numberedList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/numberedList.png -------------------------------------------------------------------------------- /frontend/src/assets/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/plusPage.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/quote.png -------------------------------------------------------------------------------- /frontend/src/assets/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/text.png -------------------------------------------------------------------------------- /frontend/src/assets/toggle-default.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/toggle-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/toggledList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/assets/toggledList.png -------------------------------------------------------------------------------- /frontend/src/assets/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/BlockContent/BlockContent.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import BlockContent from '.'; 4 | 5 | const desc = { 6 | component: BlockContent, 7 | title: 'Atoms/BlockContent', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return
; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/BlockContent/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | import React, { useEffect, useRef, KeyboardEvent, useState } from 'react'; 5 | import { useRecoilValue, useRecoilState } from 'recoil'; 6 | 7 | import { 8 | blockRefState, 9 | throttleState, 10 | modalState, 11 | draggingBlockState, 12 | } from '@/stores'; 13 | import { Block, BlockType } from '@/schemes'; 14 | import { 15 | validateType, 16 | fontSize, 17 | placeHolder, 18 | listBlockType, 19 | } from '@utils/blockContent'; 20 | import { useCommand, useManager } from '@/hooks'; 21 | import { focusState } from '@/stores/page'; 22 | import { moveBlock } from '@/utils'; 23 | 24 | const isGridOrColumn = (block: Block): boolean => 25 | block.type === BlockType.GRID || block.type === BlockType.COLUMN; 26 | 27 | const blockContentCSS = css` 28 | position: relative; 29 | display: flex; 30 | align-items: stretch; 31 | `; 32 | const editableDivCSS = (block: Block) => css` 33 | margin: 5; 34 | font-size: ${fontSize[block.type]}; 35 | display: ${!isGridOrColumn(block) ? 'flex' : 'none'}; 36 | align-items: flex-start; 37 | max-width: 100%; 38 | width: 100%; 39 | min-height: 30px; 40 | white-space: pre-wrap; 41 | word-break: break-word; 42 | caret-color: rgb(55, 53, 47); 43 | padding: 3px 2px; 44 | min-height: 1em; 45 | color: rgb(55, 53, 47); 46 | fill: inherit; 47 | &:focus { 48 | outline: 1px solid transparent; 49 | } 50 | &:focus:empty:before { 51 | color: rgba(55, 53, 47, 0.4); 52 | content: attr(placeholder); 53 | display: block; 54 | } 55 | &:hover { 56 | cursor: text; 57 | } 58 | `; 59 | const dragOverCss = () => css` 60 | position: absolute; 61 | bottom: 0; 62 | width: 100%; 63 | height: 15%; 64 | background-color: rgba(80, 188, 223, 0.7); 65 | `; 66 | 67 | function BlockContent(blockDTO: Block) { 68 | const contentEditableRef = useRef(null); 69 | const [modal, setModal] = useRecoilState(modalState); 70 | const focusId = useRecoilValue(focusState); 71 | const listCnt = useRef(1); 72 | const [Dispatcher] = useCommand(); 73 | const [{ blockIndex, prevSiblings }, { setBlock, deleteBlock }] = useManager( 74 | blockDTO.id, 75 | ); 76 | const [draggingBlock, setDraggingBlock] = useRecoilState(draggingBlockState); 77 | const [dragOverToggle, setDragOverToggle] = useState(false); 78 | 79 | useEffect(() => { 80 | blockRefState[blockDTO.id] = contentEditableRef; 81 | return () => { 82 | blockRefState[blockDTO.id] = null; 83 | }; 84 | }, []); 85 | 86 | useEffect(() => { 87 | if (focusId === blockDTO.id) contentEditableRef.current.focus(); 88 | }, [focusId]); 89 | 90 | const upperBlocks: Array = prevSiblings?.reverse(); 91 | 92 | const isUpperBlockEqualToNumberList = (): boolean => { 93 | if (upperBlocks.length) { 94 | return upperBlocks[0].type === BlockType.NUMBERED_LIST; 95 | } 96 | return false; 97 | }; 98 | 99 | const cntOfUpperNumberListBlock = (): number => { 100 | let cnt = 0; 101 | if (upperBlocks) { 102 | for (const upperblock of upperBlocks) { 103 | if (upperblock.type !== BlockType.NUMBERED_LIST) break; 104 | cnt += 1; 105 | } 106 | } 107 | return cnt; 108 | }; 109 | 110 | const FIRST_LIST_NUMBER = '1'; 111 | 112 | const handleBlock = async ( 113 | value: string, 114 | type?: BlockType, 115 | caretOffset = -1, 116 | ) => { 117 | await setBlock(blockDTO.id, { value, type: type || blockDTO.type }); 118 | }; 119 | 120 | const handleValue = async () => { 121 | const content = contentEditableRef.current?.textContent ?? ''; 122 | if (blockDTO.value !== content) { 123 | await handleBlock(content); 124 | } 125 | onBlurHandler(); 126 | }; 127 | const updateValue = handleValue; 128 | 129 | const handleKeyDown = async (event: KeyboardEvent) => { 130 | const { focusNode, focusOffset, anchorOffset } = window.getSelection(); 131 | if (throttleState.isThrottle) { 132 | event.preventDefault(); 133 | } else if ( 134 | event.key === 'ArrowUp' || 135 | event.key === 'ArrowDown' || 136 | (event.key === 'ArrowLeft' && !focusOffset) || 137 | (event.key === 'ArrowRight' && 138 | focusOffset === 139 | ((focusNode as any).length ?? (focusNode as any).innerText.length)) || 140 | (event.key === 'Enter' && !event.shiftKey) || 141 | event.key === 'Tab' || 142 | (event.key === 'Backspace' && 143 | !focusOffset && 144 | focusOffset === anchorOffset) 145 | ) { 146 | throttleState.isThrottle = true; 147 | event.preventDefault(); 148 | setImmediate(async () => { 149 | await Dispatcher((event.shiftKey ? 'shift' : '') + event.key); 150 | throttleState.isThrottle = false; 151 | }); 152 | } else if (event.key === 'Enter' && event.shiftKey) { 153 | const { textContent } = contentEditableRef.current; 154 | const caretOffset = window.getSelection().focusOffset; 155 | const cvTextContent = textContent 156 | .slice(0, caretOffset) 157 | .concat('\n', textContent.slice(caretOffset)); 158 | handleBlock(cvTextContent, null, caretOffset + 1); 159 | } else if (event.key === ' ') { 160 | const { focusOffset: caretOffset } = window.getSelection(); 161 | const beforeContent = (contentEditableRef.current?.textContent ?? 162 | '') as string; 163 | const content = beforeContent 164 | .slice(0, caretOffset) 165 | .concat(' ', beforeContent.slice(caretOffset)); 166 | 167 | const newType = validateType(content.split(' ', 1)[0]); 168 | if (newType) { 169 | event.preventDefault(); 170 | if (newType === BlockType.NUMBERED_LIST) { 171 | const frontContent = content 172 | .split(' ', 1)[0] 173 | .slice(0, content.length - 2); 174 | if (!blockIndex && frontContent !== FIRST_LIST_NUMBER) { 175 | return; 176 | } 177 | if (blockIndex) { 178 | const numberListUpperBlock = isUpperBlockEqualToNumberList(); 179 | if (!numberListUpperBlock && frontContent !== FIRST_LIST_NUMBER) { 180 | return; 181 | } 182 | if ( 183 | numberListUpperBlock && 184 | cntOfUpperNumberListBlock() + 1 !== +frontContent 185 | ) { 186 | return; 187 | } 188 | } 189 | } 190 | const slicedContent = content.slice( 191 | content.indexOf(' ') + 1, 192 | content.length, 193 | ); 194 | contentEditableRef.current.innerText = slicedContent; 195 | await handleBlock(slicedContent, newType); 196 | } 197 | } else if (event.key === '/') { 198 | const nowLetterIdx = window.getSelection().focusOffset; 199 | setTimeout(() => { 200 | const rect = window.getSelection().getRangeAt(0).getClientRects()[0]; 201 | setModal({ 202 | isOpen: true, 203 | top: rect.top, 204 | left: rect.left, 205 | caretOffset: nowLetterIdx, 206 | blockId: blockDTO.id, 207 | }); 208 | }); 209 | } else if (modal.isOpen) { 210 | setModal({ ...modal, isOpen: false }); 211 | } 212 | }; 213 | 214 | if ( 215 | (blockDTO.type === BlockType.GRID || blockDTO.type === BlockType.COLUMN) && 216 | !blockDTO.childIdList.length 217 | ) { 218 | setImmediate(async () => { 219 | await deleteBlock(); 220 | }); 221 | } 222 | 223 | useEffect(() => { 224 | blockRefState[blockDTO.id] = contentEditableRef; 225 | return () => { 226 | blockRefState[blockDTO.id] = null; 227 | }; 228 | }, []); 229 | 230 | useEffect(() => { 231 | if (focusId === blockDTO.id) { 232 | contentEditableRef.current.focus(); 233 | } 234 | }, [focusId]); 235 | 236 | useEffect(() => { 237 | if (blockDTO.type === BlockType.NUMBERED_LIST) { 238 | const numberListUpperBlock = isUpperBlockEqualToNumberList(); 239 | if (!blockIndex || !numberListUpperBlock) { 240 | listCnt.current = 1; 241 | return; 242 | } 243 | if (numberListUpperBlock) { 244 | listCnt.current = cntOfUpperNumberListBlock() + 1; 245 | } 246 | } 247 | }, [blockDTO.type]); 248 | 249 | const dragOverHandler = (event: React.DragEvent) => { 250 | event.dataTransfer.dropEffect = 'move'; 251 | event.preventDefault(); 252 | }; 253 | 254 | const dropHandler = async (event: React.DragEvent) => { 255 | setDragOverToggle(false); 256 | event.dataTransfer.dropEffect = 'move'; 257 | 258 | const blockId = draggingBlock?.id; 259 | if (!blockId || blockId === blockDTO.id) { 260 | return; 261 | } 262 | 263 | const { block, from: fromBlock, to } = await moveBlock({ 264 | blockId, 265 | toId: blockDTO.parentId, 266 | index: blockIndex + 1, 267 | }); 268 | await setBlock(block.id, block); 269 | fromBlock && (await setBlock(fromBlock.id, fromBlock)); 270 | await setBlock(to.id, to); 271 | setDraggingBlock(null); 272 | event.preventDefault(); 273 | }; 274 | 275 | const onBlurHandler = () => { 276 | setModal({ 277 | ...modal, 278 | isOpen: false, 279 | }); 280 | }; 281 | 282 | return ( 283 |
setDragOverToggle(true)} 288 | onDragLeave={() => setDragOverToggle(false)} 289 | > 290 | {listBlockType(blockDTO, listCnt.current)} 291 |
301 | {blockDTO.value} 302 |
303 | {dragOverToggle &&
} 304 |
305 | ); 306 | } 307 | 308 | export default BlockContent; 309 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/BlockContent/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './BlockContent'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderButton/HeaderButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HeaderButton from '.'; 4 | 5 | const desc = { 6 | component: HeaderButton, 7 | title: 'Atoms/HeaderButton', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return Button; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderButton/HeaderButton.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/atoms/HeaderButton/HeaderButton.test.ts -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderButton/HeaderButton.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css, SerializedStyles } from '@emotion/react'; 4 | 5 | const buttonCss = (): SerializedStyles => css` 6 | display: flex; 7 | text-decoration: none; 8 | user-select: none; 9 | cursor: pointer; 10 | color: inherit; 11 | min-width: 0px; 12 | padding: 6px; 13 | margin: auto; 14 | white-space: nowrap; 15 | border-radius: 3px; 16 | font-size: inherit; 17 | border: 0; 18 | outline: 0; 19 | 20 | &:hover { 21 | background-color: #cccccc; 22 | } 23 | `; 24 | 25 | function HeaderButton({ children, clickHandler }: any): JSX.Element { 26 | return ( 27 |
34 | {children} 35 |
36 | ); 37 | } 38 | 39 | export default HeaderButton; 40 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './HeaderButton'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderLink/HeaderLink.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HeaderLink from '.'; 4 | 5 | const desc = { 6 | component: HeaderLink, 7 | title: 'Atoms/HeaderLink', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return Link; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderLink/HeaderLink.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/atoms/HeaderLink/HeaderLink.test.ts -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderLink/HeaderLink.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | const buttonCss = () => css` 6 | display: flow-root; 7 | width: 100%; 8 | max-width: 400px; 9 | padding: 6px; 10 | margin: auto; 11 | margin-left: 10px; 12 | border-radius: 3px; 13 | font-size: inherit; 14 | color: inherit; 15 | overflow: hidden; 16 | white-space: nowrap; 17 | text-overflow: ellipsis; 18 | text-decoration: none; 19 | user-select: none; 20 | cursor: pointer; 21 | 22 | &:hover { 23 | background-color: #cccccc; 24 | } 25 | `; 26 | 27 | function HeaderLink({ children }: any): JSX.Element { 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | } 34 | 35 | export default HeaderLink; 36 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/HeaderLink/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './HeaderLink'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Heading/Heading.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Heading from '.'; 4 | 5 | const desc = { 6 | component: Heading.Heading1, 7 | title: 'Atoms/Heading', 8 | }; 9 | 10 | export const HEADING1 = (): JSX.Element => { 11 | const headingProps = { 12 | handleValue: () => {}, 13 | content: { current: 'Heading1' }, 14 | }; 15 | return ; 16 | }; 17 | 18 | export const HEADING2 = (): JSX.Element => { 19 | const headingProps = { 20 | handleValue: () => {}, 21 | content: { current: 'Heading2' }, 22 | }; 23 | return ; 24 | }; 25 | 26 | export const HEADING3 = (): JSX.Element => { 27 | const headingProps = { 28 | handleValue: () => {}, 29 | content: { current: 'Heading3' }, 30 | }; 31 | return ; 32 | }; 33 | 34 | export default desc; 35 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Heading/Heading.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/atoms/Heading/Heading.test.ts -------------------------------------------------------------------------------- /frontend/src/components/atoms/Heading/Heading.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx } from '@emotion/react'; 4 | 5 | const h1 = { margin: 5, fontSize: 'xx-large' }; 6 | const h2 = { margin: 5, fontSize: 'x-large' }; 7 | const h3 = { margin: 5, fontSize: 'large' }; 8 | 9 | interface HeadingProps { 10 | handleValue: any; 11 | content: any; 12 | } 13 | 14 | function Heading1(compoProps: HeadingProps): JSX.Element { 15 | return ( 16 |
22 | {compoProps.content.current} 23 |
24 | ); 25 | } 26 | 27 | function Heading2(compoProps: HeadingProps): JSX.Element { 28 | return ( 29 |
35 | {compoProps.content.current} 36 |
37 | ); 38 | } 39 | 40 | function Heading3(compoProps: HeadingProps): JSX.Element { 41 | return ( 42 |
48 | {compoProps.content.current} 49 |
50 | ); 51 | } 52 | 53 | export default { Heading1, Heading2, Heading3 }; 54 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Heading/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Heading'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Text/Text.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Text from '.'; 4 | 5 | const desc = { 6 | component: Text, 7 | title: 'Atoms/Text', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx } from '@emotion/react'; 4 | 5 | function Text(): JSX.Element { 6 | return ( 7 |
{}} 12 | > 13 | asfd 14 |
15 | ); 16 | } 17 | 18 | export default Text; 19 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HeaderLink } from './HeaderLink'; 2 | export { default as HeaderButton } from './HeaderButton'; 3 | export { default as Heading } from './Heading'; 4 | export { default as Text } from './Text'; 5 | export { default as BlockContent } from './BlockContent'; 6 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockComponent/BlockComponent.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { fetchDummyData } from '@/utils'; 4 | import BlockComponent from '.'; 5 | 6 | const desc = { 7 | component: BlockComponent, 8 | title: 'Atoms/Block', 9 | }; 10 | 11 | export const Default = (): JSX.Element => { 12 | const [block, setBlock] = useState(null); 13 | useEffect(() => { 14 | (async () => { 15 | const { page, blockMap } = await fetchDummyData(); 16 | const rootBlock = blockMap[page.rootId]; 17 | setBlock(blockMap[rootBlock.childIdList[0]]); 18 | })(); 19 | }); 20 | 21 | return ; 22 | }; 23 | 24 | export const HasDescendants = (): JSX.Element => { 25 | const [block, setBlock] = useState(null); 26 | useEffect(() => { 27 | (async () => { 28 | const { page, blockMap } = await fetchDummyData(); 29 | const rootBlock = blockMap[page.rootId]; 30 | setBlock(blockMap[rootBlock.childIdList[1]]); 31 | })(); 32 | }); 33 | return ; 34 | }; 35 | 36 | export const Grid = (): JSX.Element => { 37 | const [block, setBlock] = useState(null); 38 | useEffect(() => { 39 | (async () => { 40 | const { page, blockMap } = await fetchDummyData(); 41 | const rootBlock = blockMap[page.rootId]; 42 | setBlock(blockMap[rootBlock.childIdList[2]]); 43 | })(); 44 | }); 45 | return ; 46 | }; 47 | 48 | export default desc; 49 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockComponent/BlockComponent.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/molecules/BlockComponent/BlockComponent.test.ts -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockComponent/BlockComponent.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | import React, { useRef } from 'react'; 5 | import { useRecoilState, useRecoilValue } from 'recoil'; 6 | 7 | import { BlockContent } from '@atoms/index'; 8 | import { BlockHandler, HoverArea } from '@components/molecules'; 9 | import { Block, BlockType, IdType } from '@/schemes'; 10 | import { 11 | hoverState, 12 | focusState, 13 | blockRefState, 14 | blockMapState, 15 | } from '@stores/page'; 16 | 17 | const isGridOrColumn = (block: Block): boolean => 18 | block.type === BlockType.GRID || block.type === BlockType.COLUMN; 19 | 20 | const blockCss = css` 21 | width: 100%; 22 | max-width: 1000px; 23 | margin-top: 3px; 24 | margin-bottom: 3px; 25 | font-size: 16px; 26 | line-height: 1.5; 27 | color: inherit; 28 | fill: inherit; 29 | `; 30 | const descendantsCss = (block: Block) => css` 31 | display: flex; 32 | padding-left: ${!isGridOrColumn(block) ? '1.5rem' : 0}; 33 | flex-direction: ${block.type !== BlockType.GRID ? 'column' : 'row'}; 34 | color: inherit; 35 | fill: inherit; 36 | `; 37 | 38 | interface Props { 39 | blockDTO: Block; 40 | } 41 | 42 | function BlockComponent({ blockDTO }: Props): JSX.Element { 43 | const blockMap = useRecoilValue(blockMapState); 44 | const [focusId, setFocusId] = useRecoilState(focusState); 45 | const [hoverId, setHoverId] = useRecoilState(hoverState); 46 | const blockRef: any = blockRefState[blockDTO.id]; 47 | const blockComponentRef = useRef(null); 48 | 49 | return ( 50 |
51 |
setHoverId(blockDTO.id)} 54 | onMouseLeave={() => setHoverId(null)} 55 | onFocus={() => { 56 | if (focusId !== blockDTO.id) setFocusId(blockDTO.id); 57 | }} 58 | > 59 | 60 | blockRef.current.focus()} /> 61 | {hoverId === blockDTO.id && ( 62 | 66 | )} 67 |
68 | {blockDTO.childIdList.length ? ( 69 |
70 | {blockDTO.childIdList.map((blockId: IdType) => ( 71 | 72 | ))} 73 |
74 | ) : ( 75 | '' 76 | )} 77 |
78 | ); 79 | } 80 | 81 | export default BlockComponent; 82 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockComponent/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './BlockComponent'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockHandler/BlockHandler.stories.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx } from '@emotion/react'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | import { BlockComponent } from '@components/molecules'; 7 | import { fetchDummyData } from '@/utils'; 8 | import BlockHandler from '.'; 9 | 10 | const desc = { 11 | component: BlockHandler, 12 | title: 'Molecules/BlockHandler', 13 | }; 14 | 15 | export const Default = (): JSX.Element => { 16 | const [block, setBlock] = useState(null); 17 | useEffect(() => { 18 | (async () => { 19 | const { page, blockMap } = await fetchDummyData(); 20 | const rootBlock = blockMap[page.rootId]; 21 | setBlock(blockMap[rootBlock.childIdList[2]]); 22 | })(); 23 | }); 24 | return ( 25 |
26 | 27 |
28 | ); 29 | }; 30 | 31 | export default desc; 32 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockHandler/BlockHandler.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime classic */ 2 | /** @jsx jsx */ 3 | import { jsx, css } from '@emotion/react'; 4 | import React from 'react'; 5 | 6 | import { ReactComponent as DraggableIcon } from '@assets/draggable.svg'; 7 | import { ReactComponent as PlusIcon } from '@assets/plus.svg'; 8 | import { useSetRecoilState } from 'recoil'; 9 | 10 | import { draggingBlockState, modalState, focusState } from '@/stores'; 11 | import { Block } from '@/schemes'; 12 | 13 | const buttonWrapperCss = () => css` 14 | display: flex; 15 | height: 100%; 16 | position: absolute; 17 | top: 0; 18 | align-items: center; 19 | left: -40px; 20 | & svg { 21 | margin-right: 2px; 22 | } 23 | & svg:hover { 24 | cursor: grab; 25 | background-color: rgba(55, 53, 47, 0.4); 26 | border-radius: 3px; 27 | } 28 | `; 29 | const buttonCss = () => css` 30 | display: inline-block; 31 | height: 16px; 32 | `; 33 | 34 | interface Props { 35 | blockDTO: Block; 36 | blockComponentRef: any; 37 | } 38 | 39 | function BlockHandler({ blockDTO, blockComponentRef }: Props): JSX.Element { 40 | const setDraggingBlock = useSetRecoilState(draggingBlockState); 41 | const setModal = useSetRecoilState(modalState); 42 | const setFocus = useSetRecoilState(focusState); 43 | 44 | const dragStartHandler = (event: React.DragEvent) => { 45 | event.dataTransfer.effectAllowed = 'move'; 46 | event.dataTransfer.dropEffect = 'move'; 47 | event.dataTransfer.setDragImage(blockComponentRef.current, 0, 0); 48 | 49 | setDraggingBlock(blockDTO); 50 | }; 51 | 52 | const handleModal = (event: React.MouseEvent) => { 53 | setFocus(blockDTO.id); 54 | setModal({ 55 | isOpen: true, 56 | top: event.clientY, 57 | left: event.clientX, 58 | caretOffset: blockDTO.value.length + 1, 59 | blockId: blockDTO.id, 60 | }); 61 | }; 62 | 63 | return ( 64 |
65 | 66 |
setDraggingBlock(blockDTO)} 71 | onMouseLeave={() => setDraggingBlock(null)} 72 | > 73 | 74 |
75 |
76 | ); 77 | } 78 | 79 | export default BlockHandler; 80 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockHandler/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './BlockHandler'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockModal/BlockModal.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css, keyframes } from '@emotion/react'; 4 | import { ReactPortal, MouseEvent, useRef, useEffect } from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | import { useRecoilState } from 'recoil'; 8 | import { modalState, blockRefState } from '@/stores'; 9 | import { useManager } from '@/hooks'; 10 | 11 | import TextImg from '@assets/text.png'; 12 | import H1Img from '@assets/heading1.png'; 13 | import H2Img from '@assets/heading2.png'; 14 | import H3Img from '@assets/heading3.png'; 15 | import BulletedListImg from '@assets/bulletedList.png'; 16 | import NumberedListImg from '@assets/numberedList.png'; 17 | import QuoteImg from '@assets/quote.png'; 18 | 19 | const fadein = keyframes` 20 | from { 21 | opacity:0; 22 | } 23 | to { 24 | opacity:1; 25 | } 26 | `; 27 | 28 | const modalWrapperCss = (left: number, top: number) => css` 29 | position: fixed; 30 | left: ${left}px; 31 | top: ${top}px; 32 | height: 300px; 33 | width: 250px; 34 | display: flex; 35 | flex-direction: column; 36 | justify-content: space-between; 37 | align-items: center; 38 | padding-left: calc(96px + env(safe-area-inset-left)); 39 | padding-right: calc(96px + env(safe-area-inset-right)); 40 | max-width: 100%; 41 | margin: auto; 42 | border: solid 0.5px rgba(0, 0, 0, 0.1); 43 | border-radius: 5px; 44 | box-shadow: 0px 0px 15px 3px rgba(0, 0, 0, 0.1); 45 | background-color: white; 46 | z-index: 2; 47 | overflow-y: scroll; 48 | animation: ${fadein} 0.3s; 49 | .header { 50 | font-size: 12px; 51 | font-weight: 20px; 52 | color: rgba(0, 0, 0, 0.5); 53 | } 54 | `; 55 | 56 | const modalComponentCss = css` 57 | display: flex; 58 | padding: 5px; 59 | width: inherit; 60 | font-size: 15px; 61 | div { 62 | margin: 4px; 63 | } 64 | img { 65 | width: 50px; 66 | height: 50px; 67 | border: 1px solid rgba(0, 0, 0, 0.1); 68 | border-radius: 5px; 69 | } 70 | .des { 71 | font-size: 12px; 72 | color: rgba(0, 0, 0, 0.3); 73 | } 74 | :hover { 75 | background-color: rgba(0, 0, 0, 0.1); 76 | } 77 | `; 78 | 79 | const typeName: { [key: string]: string } = { 80 | text: 'Text', 81 | heading1: 'Heading1', 82 | heading2: 'Heading2', 83 | heading3: 'Heading3', 84 | bulletedlist: 'Bulleted list', 85 | numberedlist: 'Numbered list', 86 | quote: 'Quote', 87 | }; 88 | 89 | const typeObj: { [key: string]: string } = { 90 | text: 'Just start writing with plain text.', 91 | heading1: 'Big section heading.', 92 | heading2: 'Medium section heading.', 93 | heading3: 'Small section heading.', 94 | bulletedlist: 'Create a simple bulleted list.', 95 | numberedlist: 'Create a list with numbering.', 96 | quote: 'Capture a quote.', 97 | }; 98 | 99 | const typeImg: { [key: string]: any } = { 100 | text: TextImg, 101 | heading1: H1Img, 102 | heading2: H2Img, 103 | heading3: H3Img, 104 | bulletedlist: BulletedListImg, 105 | numberedlist: NumberedListImg, 106 | quote: QuoteImg, 107 | }; 108 | 109 | function ModalPortal({ children }: any): ReactPortal { 110 | const el = document.getElementById('modal'); 111 | return ReactDOM.createPortal(children, el); 112 | } 113 | 114 | function BlockModal(): JSX.Element { 115 | const [modal, setModal] = useRecoilState(modalState); 116 | const [ 117 | { block, blockIndex }, 118 | { insertNewSibling, setBlock, setFocus }, 119 | ] = useManager(modal.blockId); 120 | const modalEL = useRef(); 121 | 122 | const createBlockHandler = async (type: string) => { 123 | const newBlock = await insertNewSibling({ type }, blockIndex + 1); 124 | const text = blockRefState[block.id].current.textContent; 125 | const content = 126 | text.substring(0, modal.caretOffset) + 127 | text.substring(modal.caretOffset + 1); 128 | await setBlock(modal.blockId, { value: content }); 129 | setFocus(newBlock); 130 | }; 131 | 132 | const onClickType = (type: string) => ( 133 | event: MouseEvent, 134 | ) => { 135 | createBlockHandler(type); 136 | setModal({ ...modal, isOpen: false }); 137 | }; 138 | 139 | const handleClickOutside = ({ target }: any) => { 140 | if (modal.isOpen && !modalEL.current.contains(target)) 141 | setModal({ ...modal, isOpen: false }); 142 | }; 143 | 144 | useEffect(() => { 145 | window.addEventListener('click', handleClickOutside); 146 | return () => { 147 | window.removeEventListener('click', handleClickOutside); 148 | }; 149 | }, []); 150 | 151 | return ( 152 | 153 |
154 | {Object.keys(typeName).map((type) => ( 155 |
{}} 160 | > 161 |
162 | {type} 163 |
164 |
165 |
{typeName[type]}
166 |
{typeObj[type]}
167 |
168 |
169 | ))} 170 |
171 |
172 | ); 173 | } 174 | 175 | export default BlockModal; 176 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/BlockModal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './BlockModal'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Editor/Editor.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Editor from '.'; 4 | 5 | const desc = { 6 | component: Editor, 7 | title: 'molecules/Editor', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Editor/Editor.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/molecules/Editor/Editor.test.ts -------------------------------------------------------------------------------- /frontend/src/components/molecules/Editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | import { BlockComponent } from '@components/molecules'; 6 | import { useRecoilValue } from 'recoil'; 7 | import { pageState, blockMapState } from '@/stores'; 8 | import { Suspense } from 'react'; 9 | 10 | const wrapperCss = () => css` 11 | padding-left: calc(96px + env(safe-area-inset-left)); 12 | padding-right: calc(96px + env(safe-area-inset-right)); 13 | max-width: 100%; 14 | width: 900px; 15 | margin: auto; 16 | `; 17 | 18 | function Editor(): JSX.Element { 19 | const page = useRecoilValue(pageState); 20 | const blockMap = useRecoilValue(blockMapState); 21 | 22 | return ( 23 |
24 | 로딩 중...
}> 25 | {blockMap[page.rootId]?.childIdList.map((blockId: string) => ( 26 | 27 | ))} 28 | 29 |
30 | ); 31 | } 32 | 33 | export default Editor; 34 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Editor'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Header/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Header from '.'; 4 | 5 | const desc = { 6 | component: Header, 7 | title: 'molecules/Header', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return
; 12 | }; 13 | 14 | export const MenuOpened = (): JSX.Element => { 15 | return
; 16 | }; 17 | 18 | export default desc; 19 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Header/Header.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/molecules/Header/Header.test.ts -------------------------------------------------------------------------------- /frontend/src/components/molecules/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | import { HeaderLink, HeaderButton } from '@components/atoms'; 6 | import { useRecoilValue } from 'recoil'; 7 | import { useEffect, useState } from 'react'; 8 | import { 9 | pageState, 10 | staticMenuToggleState, 11 | pageUserCountState, 12 | lastUpdateState, 13 | } from '@/stores'; 14 | import { timeSince } from '@/utils'; 15 | import { boolean } from '@storybook/addon-knobs'; 16 | 17 | const headerCss = () => css` 18 | width: 100%; 19 | max-width: 100vw; 20 | height: 45px; 21 | position: relative; 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | overflow: hidden; 26 | padding-left: 12px; 27 | padding-right: 10px; 28 | `; 29 | const wrapperCss = () => css` 30 | display: flex; 31 | align-items: center; 32 | line-height: 1.2; 33 | font-size: 14px; 34 | height: 100%; 35 | flex-grow: 0; 36 | margin-right: 8px; 37 | min-width: 0; 38 | `; 39 | const menuMarginCss = () => css` 40 | width: 30px; 41 | `; 42 | 43 | interface Props {} 44 | 45 | function Header({}: Props): JSX.Element { 46 | const staticMenuToggle = useRecoilValue(staticMenuToggleState); 47 | const selectedPage = useRecoilValue(pageState); 48 | const [tic, setTic] = useState(true); 49 | const pageUserCount = useRecoilValue(pageUserCountState); 50 | const lastUpdate = useRecoilValue(lastUpdateState); 51 | 52 | const date = `Updated ${timeSince(lastUpdate)} ago`; 53 | const people = `${pageUserCount}명 접속중`; 54 | 55 | useEffect(() => { 56 | const id = setInterval(() => { 57 | setTic(!tic); 58 | }, 1000); 59 | return () => { 60 | clearInterval(id); 61 | }; 62 | }); 63 | 64 | return ( 65 |
66 |
67 | {!staticMenuToggle &&
} 68 | {selectedPage?.title || 'Untitled'} 69 |
70 |
71 | {date} 72 | {people} 73 |
74 |
75 | ); 76 | } 77 | 78 | export default Header; 79 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/HoverArea/HoverArea.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | import { MouseEvent } from 'react'; 5 | import { useRecoilValue } from 'recoil'; 6 | import { draggingBlockState } from '@/stores'; 7 | 8 | const leftHoverAreaCss = css` 9 | position: absolute; 10 | top: 0; 11 | right: 100%; 12 | width: calc(10% + 36px); 13 | height: 100%; 14 | `; 15 | const rightHoverAreaCss = css` 16 | position: absolute; 17 | top: 0; 18 | left: 100%; 19 | width: 10%; 20 | height: 100%; 21 | `; 22 | const commonHoverAreaCss = css` 23 | &:hover { 24 | cursor: text; 25 | } 26 | `; 27 | 28 | interface Props { 29 | clickHandler: ( 30 | event: MouseEvent, 31 | ) => void; 32 | } 33 | 34 | function HoverArea({ clickHandler }: Props): JSX.Element { 35 | const draggingBlock = useRecoilValue(draggingBlockState); 36 | 37 | return ( 38 |
{}}> 39 | {!draggingBlock &&
} 40 |
41 |
42 | ); 43 | } 44 | 45 | export default HoverArea; 46 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/HoverArea/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './HoverArea'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Menu/Menu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Menu from '.'; 4 | 5 | const desc = { 6 | component: Menu, 7 | title: 'molecules/Menu', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Menu/Menu.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/molecules/Menu/Menu.test.ts -------------------------------------------------------------------------------- /frontend/src/components/molecules/Menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | import { Suspense } from 'react'; 5 | import { useRecoilState, useSetRecoilState } from 'recoil'; 6 | 7 | import { 8 | blockMapState, 9 | hoveredMenuToggleState, 10 | pagesState, 11 | pageState, 12 | staticMenuToggleState, 13 | } from '@/stores'; 14 | import { createPage, readBlockMap } from '@/utils'; 15 | import { HeaderButton } from '@atoms/index'; 16 | import { ReactComponent as DoubleChevronLeft } from '@assets/doubleChevronLeft.svg'; 17 | import { ReactComponent as PlusPage } from '@assets/plusPage.svg'; 18 | import { ReactComponent as Loading } from '@assets/loading.svg'; 19 | import { MenuItem } from '@molecules/index'; 20 | import { animated, useSpring } from 'react-spring'; 21 | import styled from '@emotion/styled'; 22 | 23 | const wrapperCss = (staticMenuToggle: boolean) => css` 24 | position: relative; 25 | width: 240px; 26 | height: ${staticMenuToggle ? 100 : 80}vh; 27 | border-radius: ${staticMenuToggle ? 0 : '3px'}; 28 | background: ${staticMenuToggle ? 'rgb(247, 246, 243)' : '#ffffff'}; 29 | ${staticMenuToggle ? '' : 'box-shadow: 0 0 10px 1px #aaaaaa'}; 30 | `; 31 | const workspaceCss = () => css` 32 | display: flex; 33 | align-items: center; 34 | min-height: 24px; 35 | font-size: 14px; 36 | padding: 14px 14px 14px 15px; 37 | width: 100%; 38 | color: rgba(55, 53, 47, 0.6); 39 | `; 40 | const plusCss = (staticMenuToggle: boolean) => css` 41 | margin-right: ${staticMenuToggle ? 5 : 0}px; 42 | border: 1px solid rgba(55, 53, 47, 0.16); 43 | border-radius: 3px; 44 | `; 45 | const menuListCss = () => css` 46 | overflow-y: auto; 47 | height: calc(100% - 44px); 48 | `; 49 | const AnimatedButtons = styled(animated.div)` 50 | position: absolute; 51 | top: 7px; 52 | right: 0; 53 | display: flex; 54 | align-items: center; 55 | line-height: 1.2; 56 | font-size: 14px; 57 | flex-grow: 0; 58 | margin-right: 14px; 59 | min-width: 0; 60 | `; 61 | 62 | function Menu(): JSX.Element { 63 | const [pages, setPages] = useRecoilState(pagesState); 64 | const setSelectedPage = useSetRecoilState(pageState); 65 | const [staticMenuToggle, setStaticMenuToggle] = useRecoilState( 66 | staticMenuToggleState, 67 | ); 68 | const [hoveredMenuToggle, setHoveredMenuToggle] = useRecoilState( 69 | hoveredMenuToggleState, 70 | ); 71 | const setBlockMap = useSetRecoilState(blockMapState); 72 | const buttonStyleProps = useSpring({ 73 | opacity: hoveredMenuToggle ? 1 : 0, 74 | }); 75 | 76 | const CreatingPageHandler = async () => { 77 | const { pages: updated, page: created } = await createPage(); 78 | 79 | setBlockMap((await readBlockMap(created.id)).blockMap); 80 | setSelectedPage(created); 81 | setPages(updated); 82 | }; 83 | 84 | const clickCloseHandler = () => { 85 | setStaticMenuToggle(false); 86 | setHoveredMenuToggle(false); 87 | }; 88 | 89 | return ( 90 |
91 | 92 |
93 | 94 | 95 | 96 |
97 | {staticMenuToggle && ( 98 | 99 | 100 | 101 | )} 102 |
103 |
WORKSPACE
104 | }> 105 | {pages.map((page) => ( 106 | 107 | ))} 108 | 109 |
110 | ); 111 | } 112 | 113 | export default Menu; 114 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Menu'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/MenuItem/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { css, jsx } from '@emotion/react'; 4 | import { useRecoilState, useSetRecoilState } from 'recoil'; 5 | import { useState } from 'react'; 6 | 7 | import { Page } from '@/schemes'; 8 | import { blockMapState, pagesState, pageState } from '@/stores'; 9 | import { HeaderButton } from '@atoms/index'; 10 | import { ReactComponent as Trash } from '@assets/trash.svg'; 11 | import { deletePage, readBlockMap, refreshPages } from '@/utils'; 12 | 13 | const itemWrapperCss = () => css` 14 | position: relative; 15 | align-items: center; 16 | cursor: pointer; 17 | `; 18 | const itemCss = (isHovered: boolean) => css` 19 | min-height: 27px; 20 | padding: 2px 14px; 21 | padding-right: ${isHovered ? 8 : 14}px; 22 | background: ${isHovered ? 'rgba(55, 53, 47, 0.08)' : 'inherit'}; 23 | `; 24 | const selectedItemCss = (isHovered: boolean) => css` 25 | min-height: 27px; 26 | padding: 2px 14px; 27 | padding-right: ${isHovered ? 8 : 14}px; 28 | background: ${isHovered 29 | ? 'rgba(55, 53, 47, 0.16)' 30 | : 'rgba(55, 53, 47, 0.08)'}; 31 | `; 32 | const titleCss = () => css` 33 | color: rgb(55, 53, 47); 34 | font-size: 14px; 35 | font-weight: 600; 36 | line-height: 2.1; 37 | overflow: hidden; 38 | white-space: nowrap; 39 | text-overflow: ellipsis; 40 | `; 41 | const trashCss = () => css` 42 | position: absolute; 43 | top: 2px; 44 | right: 13px; 45 | `; 46 | 47 | interface Props { 48 | page: Page; 49 | } 50 | 51 | function MenuItem({ page }: Props): JSX.Element { 52 | const [selectedPage, setSelectedPage] = useRecoilState(pageState); 53 | const [hoverToggle, setHoverToggle] = useState(false); 54 | const setPages = useSetRecoilState(pagesState); 55 | const setBlockMap = useSetRecoilState(blockMapState); 56 | 57 | const selectPageHandler = async () => { 58 | setBlockMap((await readBlockMap(page.id)).blockMap); 59 | setSelectedPage(page); 60 | }; 61 | 62 | const deletePageHandler = async () => { 63 | await deletePage(page.id); 64 | const pages = await refreshPages(); 65 | 66 | if (page.id === selectedPage.id) { 67 | const nextSelectedPage = pages[0]; 68 | setBlockMap((await readBlockMap(nextSelectedPage.id)).blockMap); 69 | setSelectedPage(nextSelectedPage); 70 | } 71 | setPages(pages); 72 | }; 73 | 74 | return ( 75 |
setHoverToggle(true)} 78 | onMouseLeave={() => setHoverToggle(false)} 79 | > 80 |
90 |
{page.title || 'Untitled'}
91 |
92 | {hoverToggle && ( 93 |
94 | 95 | 96 | 97 |
98 | )} 99 |
100 | ); 101 | } 102 | 103 | export default MenuItem; 104 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/MenuItem/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MenuItem'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | import { useRecoilState, useSetRecoilState } from 'recoil'; 6 | import { pagesState, pageState } from '@/stores'; 7 | import { ChangeEvent, useRef } from 'react'; 8 | import { debounce, refreshPages, updatePage } from '@/utils'; 9 | import { Page } from '@/schemes'; 10 | import { useManager } from '@/hooks'; 11 | 12 | const wrapperCss = () => css` 13 | padding-left: calc(96px + env(safe-area-inset-left)); 14 | padding-right: calc(96px + env(safe-area-inset-right)); 15 | padding-bottom: 30px; 16 | max-width: 100%; 17 | width: 900px; 18 | margin: auto; 19 | `; 20 | const titlePaddingTopCss = () => css` 21 | height: 100px; 22 | `; 23 | const titleCss = () => css` 24 | display: inline-block; 25 | min-width: 100%; 26 | min-height: fit-content; 27 | padding: 3px 2px; 28 | border: none; 29 | outline: none; 30 | color: rgb(55, 53, 47); 31 | font-weight: 700; 32 | font-size: 40px; 33 | line-height: 1.2; 34 | white-space: pre-wrap; 35 | word-break: break-word; 36 | caret-color: rgb(55, 53, 47); 37 | cursor: text; 38 | overflow-y: hidden; 39 | resize: none; 40 | 41 | font-family: inter, Helvetica, 'Apple Color Emoji', Arial, sans-serif, 42 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 43 | 44 | &:empty:before { 45 | content: 'Untitled'; 46 | color: rgba(55, 53, 47, 0.15); 47 | } 48 | `; 49 | 50 | function Title(): JSX.Element { 51 | const [selectedPage, setSelectedPage] = useRecoilState(pageState); 52 | const setPages = useSetRecoilState(pagesState); 53 | const updateSelectedPage = async (page: Page) => { 54 | const { page: updatedPage } = await updatePage(page); 55 | const updatedPages = await refreshPages(); 56 | 57 | setSelectedPage(updatedPage); 58 | setPages(updatedPages); 59 | }; 60 | 61 | const titleRef = useRef(null); 62 | 63 | const handleChange = async () => { 64 | const sel = window.getSelection(); 65 | const text = (sel.focusNode as any).length 66 | ? sel.focusNode.textContent 67 | : sel.focusNode.parentElement.innerText; 68 | updateSelectedPage({ 69 | ...selectedPage, 70 | title: text, 71 | }); 72 | }; 73 | 74 | return ( 75 |
76 |
77 |
84 | {selectedPage.title} 85 |
86 |
87 | ); 88 | } 89 | 90 | export default Title; 91 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Title/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Title'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Header } from './Header'; 2 | export { default as Editor } from './Editor'; 3 | export { default as Menu } from './Menu'; 4 | export { default as BlockComponent } from './BlockComponent'; 5 | export { default as BlockHandler } from './BlockHandler'; 6 | export { default as BlockModal } from './BlockModal'; 7 | export { default as HoverArea } from './HoverArea'; 8 | export { default as Title } from './Title'; 9 | export { default as MenuItem } from './MenuItem'; 10 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/HeaderMenu/HeaderMenu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HeaderMenu from '.'; 4 | 5 | const desc = { 6 | component: HeaderMenu, 7 | title: 'organisms/HeaderMenu', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export const MenuOpened = (): JSX.Element => { 15 | return ; 16 | }; 17 | 18 | export default desc; 19 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/HeaderMenu/HeaderMenu.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/organisms/HeaderMenu/HeaderMenu.test.ts -------------------------------------------------------------------------------- /frontend/src/components/organisms/HeaderMenu/HeaderMenu.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | import styled from '@emotion/styled'; 5 | import { useRecoilState } from 'recoil'; 6 | import { animated, useSpring, useTransition } from 'react-spring'; 7 | 8 | import { HeaderButton } from '@components/atoms'; 9 | import { ReactComponent as HamburgerMenu } from '@assets/hamburgerMenu.svg'; 10 | import { ReactComponent as DoubleChevronRight } from '@assets/doubleChevronRight.svg'; 11 | import { hoveredMenuToggleState, staticMenuToggleState } from '@/stores'; 12 | import { Menu } from '@molecules/index'; 13 | 14 | const wrapperCss = css` 15 | position: relative; 16 | display: flex; 17 | align-items: center; 18 | line-height: 1.2; 19 | font-size: 14px; 20 | width: 45px; 21 | height: 45px; 22 | flex-grow: 0; 23 | margin-right: 8px; 24 | min-width: 0; 25 | `; 26 | const hoverAreaCss = css` 27 | position: absolute; 28 | display: inline-block; 29 | top: 45px; 30 | left: 0; 31 | width: 100%; 32 | height: 100vh; 33 | `; 34 | const AnimatedMenu = styled(animated.div)` 35 | position: absolute; 36 | display: inline-block; 37 | `; 38 | const buttonWrapper = css` 39 | position: relative; 40 | width: 16px; 41 | height: 16px; 42 | `; 43 | const AnimatedButton = styled(animated.div)` 44 | position: absolute; 45 | `; 46 | 47 | function HeaderMenu(): JSX.Element { 48 | const [staticMenuToggle, setStaticMenuToggle] = useRecoilState( 49 | staticMenuToggleState, 50 | ); 51 | const [hoveredMenuToggle, setHoveredMenuToggle] = useRecoilState( 52 | hoveredMenuToggleState, 53 | ); 54 | const menuStyleProps = useSpring({ 55 | top: staticMenuToggle ? 0 : 50, 56 | left: hoveredMenuToggle || staticMenuToggle ? 0 : -240, 57 | opacity: hoveredMenuToggle || staticMenuToggle ? 1 : 0, 58 | marginTop: staticMenuToggle ? 0 : 10, 59 | }); 60 | const hamburgerMenuStyleProps = useSpring({ 61 | opacity: hoveredMenuToggle ? 0 : 1, 62 | }); 63 | const doubleChevronRightStyleProps = useSpring({ 64 | opacity: hoveredMenuToggle ? 1 : 0, 65 | }); 66 | 67 | return ( 68 |
setHoveredMenuToggle(true)} 71 | onMouseLeave={() => setHoveredMenuToggle(false)} 72 | > 73 | setStaticMenuToggle(!staticMenuToggle)}> 74 | {staticMenuToggle || ( 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 | )} 84 |
85 |
86 | 87 | 88 | 89 |
90 | ); 91 | } 92 | 93 | export default HeaderMenu; 94 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/HeaderMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './HeaderMenu'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HeaderMenu } from './HeaderMenu'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PageComponent/PageComponent.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PageComponent from '.'; 4 | 5 | const desc = { 6 | component: PageComponent, 7 | title: 'pages/PageComponent', 8 | }; 9 | 10 | export const Default = (): JSX.Element => { 11 | return ; 12 | }; 13 | 14 | export default desc; 15 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PageComponent/PageComponent.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/Project18-C-Bootion/054deb0fa44d96d5ef84a069e11ca9f506c7edb4/frontend/src/components/pages/PageComponent/PageComponent.test.ts -------------------------------------------------------------------------------- /frontend/src/components/pages/PageComponent/PageComponent.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | import { Header, Title, Editor, BlockModal } from '@components/molecules'; 6 | import { HeaderMenu } from '@components/organisms'; 7 | import { useRecoilValue } from 'recoil'; 8 | import { pageState, staticMenuToggleState, modalState } from '@/stores'; 9 | import { useManager, useSocket } from '@/hooks'; 10 | import styled from '@emotion/styled'; 11 | import { animated, useSpring } from 'react-spring'; 12 | import { useEffect } from 'react'; 13 | import { pageIO } from '@/socket'; 14 | 15 | const staticMenuAreaCss = css` 16 | position: fixed; 17 | z-index: 2; 18 | `; 19 | const AnimatedStaticHeaderArea = styled(animated.div)` 20 | position: fixed; 21 | right: 0; 22 | background-color: #ffffff; 23 | z-index: 1; 24 | `; 25 | const AnimatedStaticScrollArea = styled(animated.div)` 26 | position: fixed; 27 | top: 45px; 28 | right: 0; 29 | background-color: #ffffff; 30 | height: calc(100% - 45px); 31 | overflow: auto; 32 | `; 33 | const bottomMarginCss = css` 34 | display: inline-block; 35 | width: 100%; 36 | height: calc(100% - 200px); 37 | min-height: 200px; 38 | `; 39 | 40 | function PageComponent(): JSX.Element { 41 | const { isOpen } = useRecoilValue(modalState); 42 | const staticMenuToggle = useRecoilValue(staticMenuToggleState); 43 | const page = useRecoilValue(pageState); 44 | useSocket(); 45 | const [{ children }, { insertNewChild, setFocus }] = useManager(page.rootId); 46 | useEffect(() => { 47 | pageIO.emit('join', page.id); 48 | }, [page]); 49 | 50 | const staticAreaStyleProps = useSpring({ 51 | left: staticMenuToggle ? 240 : 0, 52 | width: `calc(100% - ${staticMenuToggle ? 240 : 0}px)`, 53 | }); 54 | 55 | const createBlockHandler = async () => { 56 | if (children[children.length - 1]?.value === '') { 57 | setFocus(children[children.length - 1]); 58 | return; 59 | } 60 | const block = await insertNewChild({}, children.length); 61 | setFocus(block); 62 | }; 63 | 64 | return ( 65 |
66 |
67 | 68 |
69 | 70 |
71 | 72 | 73 | 74 | {isOpen ? <BlockModal /> : ''} 75 | <Editor /> 76 | <div 77 | css={bottomMarginCss} 78 | onClick={createBlockHandler} 79 | onKeyUp={createBlockHandler} 80 | /> 81 | </AnimatedStaticScrollArea> 82 | </div> 83 | ); 84 | } 85 | 86 | export default PageComponent; 87 | -------------------------------------------------------------------------------- /frontend/src/components/pages/PageComponent/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './PageComponent'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PageComponent } from './PageComponent'; 2 | -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCommand } from './useCommand'; 2 | export { default as useManager } from './useManager'; 3 | export { default as useFamily } from './useFamily'; 4 | export { default as useBlock } from './useBlock'; 5 | export { default as useSocket } from './useSocket'; 6 | 7 | export { useApi } from './useApi'; 8 | -------------------------------------------------------------------------------- /frontend/src/hooks/useApi.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import axios from 'axios'; 3 | 4 | export const useApi = (params: { initialUrl: string; initialData?: any }) => { 5 | const [url, setUrl] = useState(params.initialUrl); 6 | const [data, setData] = useState(params.initialData ?? null); 7 | const [isLoading, setIsLoading] = useState(false); 8 | const [isError, setIsError] = useState(false); 9 | const [refreshToggle, setRefreshToggle] = useState(true); 10 | 11 | const refresh = () => setRefreshToggle(!refreshToggle); 12 | 13 | useEffect(() => { 14 | const fetchData = async () => { 15 | setIsError(false); 16 | setIsLoading(true); 17 | 18 | try { 19 | const res = await axios.get(`/api/${url}`); 20 | setData(res.data); 21 | } catch (error) { 22 | setIsError(true); 23 | } 24 | 25 | setIsLoading(false); 26 | }; 27 | 28 | fetchData(); 29 | }, [url, refreshToggle]); 30 | 31 | return [ 32 | { data, isLoading, isError }, 33 | { setUrl, refresh }, 34 | ]; 35 | }; 36 | -------------------------------------------------------------------------------- /frontend/src/hooks/useBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from 'recoil'; 2 | import { Block } from '@/schemes'; 3 | import { blockMapState } from '@/stores'; 4 | 5 | const useBlock = ( 6 | blockId: string, 7 | ): [block: Block, setBlock: (newBlock: Block) => void] => { 8 | const [blockMap, setBlockMap] = useRecoilState(blockMapState); 9 | const setBlock = (newBlock: Block) => { 10 | setBlockMap({ ...blockMap, [blockId]: newBlock }); 11 | }; 12 | return [blockMap[blockId], setBlock]; 13 | }; 14 | 15 | export default useBlock; 16 | -------------------------------------------------------------------------------- /frontend/src/hooks/useCommand.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from 'recoil'; 2 | import { focusState, blockRefState } from '@/stores'; 3 | import { useManager } from '@/hooks'; 4 | import { BlockType } from '@/schemes'; 5 | 6 | const useCommand = () => { 7 | const [focusId] = useRecoilState(focusState); 8 | const [ 9 | { block, blockIndex, siblingsIdList, grandParent, parent, childrenIdList }, 10 | { 11 | getPrevBlock, 12 | getNextBlock, 13 | insertSibling, 14 | insertNewChild, 15 | insertNewSibling, 16 | setBlock, 17 | pullIn, 18 | pullOut, 19 | deleteBlock, 20 | setFocus, 21 | setCaretOffset, 22 | insertAndUpdate, 23 | deleteAndUpdateWithChildren, 24 | }, 25 | ] = useManager(focusId); 26 | 27 | const getSlicedValueToCaretOffset = () => { 28 | const { focusNode, anchorOffset, focusOffset } = window.getSelection(); 29 | return [ 30 | focusNode.textContent.slice(0, anchorOffset), 31 | focusNode.textContent.slice(focusOffset, Infinity), 32 | ]; 33 | }; 34 | 35 | const dispatcher = async (key: String) => { 36 | switch (key) { 37 | case 'ArrowUp': { 38 | const beforeCaretOffset = setFocus(getPrevBlock()); 39 | beforeCaretOffset !== null && setCaretOffset(beforeCaretOffset, false); 40 | break; 41 | } 42 | case 'ArrowLeft': { 43 | const beforeCaretOffset = setFocus(getPrevBlock()); 44 | beforeCaretOffset !== null && setCaretOffset(Infinity, false); 45 | break; 46 | } 47 | case 'ArrowDown': { 48 | const beforeCaretOffset = setFocus(getNextBlock()); 49 | beforeCaretOffset !== null && setCaretOffset(beforeCaretOffset, false); 50 | break; 51 | } 52 | case 'ArrowRight': { 53 | const beforeCaretOffset = setFocus(getNextBlock()); 54 | beforeCaretOffset !== null && setCaretOffset(0, false); 55 | break; 56 | } 57 | case 'Enter': { 58 | const [before, after] = getSlicedValueToCaretOffset(); 59 | const { focusOffset } = window.getSelection(); 60 | // startTransaction(); 61 | if (!focusOffset) { 62 | await insertNewSibling({}, blockIndex); 63 | setFocus(block); 64 | } else if (block.childIdList.length) { 65 | // await setBlock(block.id, { value: before }); 66 | blockRefState[block.id].current.innerText = before; 67 | const newBlock = await insertNewChild({ value: after }); 68 | // const newBlock = await insertAndUpdate( 69 | // block.id, 70 | // { value: before }, 71 | // 0, 72 | // { 73 | // value: after, 74 | // }, 75 | // ); 76 | setFocus(newBlock); 77 | } else { 78 | const type = [ 79 | BlockType.NUMBERED_LIST, 80 | BlockType.BULLETED_LIST, 81 | ].includes(block.type) 82 | ? block.type 83 | : BlockType.TEXT; 84 | // await setBlock(block.id, { value: before }); 85 | blockRefState[block.id].current.innerText = before; 86 | const newBlock = await insertNewSibling({ value: after, type }); 87 | 88 | // const newBlock = await insertAndUpdate( 89 | // parent.id, 90 | // { value: before }, 91 | // 0, 92 | // { 93 | // value: after, 94 | // type, 95 | // }, 96 | // ); 97 | setFocus(newBlock); 98 | } 99 | // commitTransaction(); 100 | break; 101 | } 102 | case 'Tab': { 103 | // startTransaction(); 104 | await pullIn(); 105 | // commitTransaction(); 106 | break; 107 | } 108 | case 'shiftTab': { 109 | // startTransaction(); 110 | await pullOut(); 111 | // commitTransaction(); 112 | break; 113 | } 114 | case 'Backspace': { 115 | // startTransaction(); 116 | if (block.type !== BlockType.TEXT) { 117 | await setBlock(block.id, { type: BlockType.TEXT }); 118 | } else if ( 119 | siblingsIdList.length - 1 === blockIndex && 120 | grandParent && 121 | grandParent.type !== BlockType.GRID 122 | ) { 123 | await pullOut(); 124 | } else { 125 | const [, after] = getSlicedValueToCaretOffset(); 126 | const prevBlock = getPrevBlock(); 127 | if (prevBlock) { 128 | await Promise.all( 129 | childrenIdList.map((id, index) => insertSibling(id, index)), 130 | ); 131 | await deleteBlock(); 132 | // const updatedBlock = await deleteAndUpdateWithChildren({ 133 | // ...prevBlock, 134 | // value: 135 | // prevBlock.value + after !== '' ? prevBlock.value + after : '', 136 | // }); 137 | if (prevBlock.value + after !== '') { 138 | setFocus( 139 | await setBlock(prevBlock.id, { 140 | value: prevBlock.value + after, 141 | }), 142 | ); 143 | } else { 144 | setFocus(prevBlock); 145 | } 146 | // setFocus(updatedBlock); 147 | setCaretOffset(prevBlock.value.length); 148 | } else if ( 149 | block.value !== '' && 150 | blockRefState[block.id].current.textContent === '' 151 | ) { 152 | setBlock(block.id, { ...block, value: '' }); 153 | } 154 | } 155 | // commitTransaction(); 156 | break; 157 | } 158 | } 159 | }; 160 | return [dispatcher]; 161 | }; 162 | 163 | export default useCommand; 164 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFamily.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent */ 2 | import { useRecoilState, useRecoilValue } from 'recoil'; 3 | import { pageState, blockMapState } from '@/stores'; 4 | import { Block, BlockType, BlockFamily, FamilyFunc, IdType } from '@/schemes'; 5 | import { useBlock } from '@/hooks'; 6 | 7 | const useFamily = (blockId: string): [BlockFamily, FamilyFunc] => { 8 | const [blockMap, setBlockMap] = useRecoilState(blockMapState); 9 | const [block] = useBlock(blockId); 10 | const [parent] = useBlock(block?.parentId); 11 | const [grandParent] = useBlock(parent?.parentId); 12 | const [greatGrandParent] = useBlock(grandParent?.parentId); 13 | const page = useRecoilValue(pageState); 14 | const getChildren = (childrenIdList: IdType[] | null) => 15 | childrenIdList?.map((childId: string) => blockMap[childId]); 16 | const childrenIdList = block?.childIdList; 17 | const children = getChildren(childrenIdList); 18 | const siblingsIdList = parent?.childIdList; 19 | const siblings = getChildren(siblingsIdList); 20 | const parentsIdList = grandParent?.childIdList; 21 | const parents = getChildren(parentsIdList); 22 | const blockIndex = parent?.childIdList?.findIndex( 23 | (_blockId: string) => _blockId === block?.id, 24 | ); 25 | const parentIndex = grandParent?.childIdList?.findIndex( 26 | (_blockId: string) => _blockId === parent?.id, 27 | ); 28 | const prevSibling = siblings?.[blockIndex - 1]; 29 | const nextSibling = siblings?.[blockIndex + 1]; 30 | const prevSiblings = siblings?.slice(0, blockIndex); 31 | const nextSiblings = siblings?.slice(blockIndex); 32 | 33 | const findLastDescendant = (targetBlock: Block) => { 34 | let currentBlock = targetBlock; 35 | while (currentBlock.childIdList.length) { 36 | currentBlock = 37 | blockMap[currentBlock.childIdList[currentBlock.childIdList.length - 1]]; 38 | } 39 | return currentBlock; 40 | }; 41 | 42 | const getTopAncestor = () => { 43 | const root = blockMap[page.rootId]; 44 | const rootChildren = root.childIdList; 45 | let currentParent = block; 46 | while (!rootChildren.includes(currentParent.id)) { 47 | currentParent = blockMap[currentParent.parentId]; 48 | } 49 | return currentParent; 50 | }; 51 | 52 | const getNextBlock = () => { 53 | if (children.length) { 54 | /* 자식이 있을 때 첫번째 자식이 다음 block 이다. */ 55 | return children[0]; 56 | } 57 | if (blockIndex !== siblings.length - 1) { 58 | switch (nextSibling.type) { 59 | case BlockType.COLUMN: 60 | return blockMap[nextSibling.childIdList[0]]; 61 | case BlockType.GRID: 62 | return blockMap[blockMap[nextSibling.childIdList[0]].childIdList[0]]; 63 | default: 64 | /** 다음 부모의 타입이 COLUMN이나 GRID가 아니면 다음 Block 이다. */ 65 | return nextSibling; 66 | } 67 | } 68 | /* block이 마지막 자식일 때. 다음 부모가 다음 Block 이다. */ 69 | const targetParentBlock = parents?.[parentIndex + 1]; 70 | if (!targetParentBlock) { 71 | const topAncestors = blockMap[page.rootId].childIdList; 72 | const topAncestor: Block = getTopAncestor(); 73 | const topAncestorIndex = topAncestors.findIndex( 74 | (_blockId) => _blockId === topAncestor.id, 75 | ); 76 | const nextTopAncestor = blockMap[topAncestors[topAncestorIndex + 1]]; 77 | if (nextTopAncestor) { 78 | switch (nextTopAncestor.type) { 79 | case BlockType.COLUMN: 80 | return blockMap[nextTopAncestor.childIdList[0]]; 81 | case BlockType.GRID: 82 | return blockMap[ 83 | blockMap[nextTopAncestor.childIdList[0]].childIdList[0] 84 | ]; 85 | default: 86 | return nextTopAncestor; 87 | } 88 | } 89 | /** 현재 블록이 마지막 블록이다. */ 90 | return null; 91 | } 92 | switch (targetParentBlock.type) { 93 | case BlockType.COLUMN: 94 | /** 다음 부모의 Type이 Column 일때 첫번째 자식이 다음 Block 이다. */ 95 | return blockMap[targetParentBlock.childIdList[0]]; 96 | case BlockType.GRID: 97 | /** 다음 부모의 Type이 Grid 일때 첫번째 자식(Column)의 자식이 다음 Block 이다. */ 98 | return blockMap[ 99 | blockMap[targetParentBlock.childIdList[0]].childIdList[0] 100 | ]; 101 | default: 102 | /** 다음 부모의 타입이 COLUMN이나 GRID가 아니면 다음 Block 이다. */ 103 | return targetParentBlock; 104 | } 105 | }; 106 | 107 | const getPrevBlock = () => { 108 | if (blockIndex) { 109 | /** blockIndex가 0이 아니면 이전 형제에서 prev block을 찾을 수 있다. */ 110 | if (prevSibling?.childIdList.length) { 111 | /* 이전 형제에게 자식이 있다면 마지막 후손이 prev block 이다. */ 112 | return findLastDescendant(prevSibling); 113 | } 114 | /* 이전 형제에게 자식이 없다면 형제가 prev block 이다. */ 115 | return prevSibling; 116 | } 117 | /* block이 첫번째 자식일 때. 부모가 이전 Block 이다. */ 118 | if (parent.type !== BlockType.COLUMN) { 119 | return parent.parentId ? parent : null; 120 | } 121 | /** 부모의 Type이 Column 이면 부모의 이전 형제의 마지막 자식이 이전 Block 이다. */ 122 | if (parentIndex) { 123 | /** 부모의 index가 0이 아니면 이전 부모의 마지막 */ 124 | const prevParentChildren = getChildren( 125 | parents[parentIndex - 1].childIdList, 126 | ); 127 | return prevParentChildren[prevParentChildren.length - 1]; 128 | } 129 | /** 부모의 index가 0이면 조부모에 이전 Block이 있다. */ 130 | if (!grandParent) { 131 | /** grandParent가 없을 경우 이전 블록이 없다는 뜻이다. */ 132 | return null; 133 | } 134 | if (grandParent.type !== BlockType.GRID) { 135 | return grandParent; 136 | } 137 | /** 조부모가 GRID 타입인 경우 조부모의 이전 형제에 이전 블록이 있다. */ 138 | const grandParentIndex = greatGrandParent.childIdList.findIndex( 139 | (_blockId: string) => _blockId === grandParent.id, 140 | ); 141 | if (!grandParentIndex) { 142 | /** grandParentIndex가 0이면 page의 blockList 중 맨 앞이라는 의미이므로 prev 블록이 없다. */ 143 | return null; 144 | } 145 | /** grandParentIndex 가 0이 아니면 prevGrandParent의 마지막 후손이 prev 블록이다. */ 146 | const prevGrandParent = 147 | blockMap[greatGrandParent.childIdList[grandParentIndex - 1]]; 148 | return findLastDescendant(prevGrandParent); 149 | }; 150 | 151 | return [ 152 | { 153 | blockMap, 154 | block, 155 | blockIndex, 156 | parent, 157 | parentIndex, 158 | grandParent, 159 | page, 160 | childrenIdList, 161 | children, 162 | siblingsIdList, 163 | siblings, 164 | parentsIdList, 165 | parents, 166 | nextSibling, 167 | prevSibling, 168 | nextSiblings, 169 | prevSiblings, 170 | }, 171 | { 172 | getNextBlock, 173 | getPrevBlock, 174 | setBlockMap, 175 | }, 176 | ]; 177 | }; 178 | 179 | export default useFamily; 180 | -------------------------------------------------------------------------------- /frontend/src/hooks/useManager.tsx: -------------------------------------------------------------------------------- 1 | import { Block, BlockType, BlockFamily, FamilyFunc, BlockMap } from '@/schemes'; 2 | import { useFamily } from '@/hooks'; 3 | import { focusState, blockRefState } from '@/stores'; 4 | import { useSetRecoilState } from 'recoil'; 5 | import { 6 | createBlock, 7 | updateBlock, 8 | moveBlock, 9 | deletePageCascade, 10 | createAndUpdate, 11 | deleteAndUpdate, 12 | } from '@utils/blockApis'; 13 | 14 | interface ManagerFunc { 15 | insertNewChild: (option?: any, insertIndex?: number) => Promise<Block>; 16 | insertNewSibling: (option?: any, insertIndex?: number) => Promise<Block>; 17 | insertSibling: (id: string, inserIndex?: number) => Promise<Block>; 18 | setBlock: (id: string, option?: any) => Promise<Block>; 19 | startTransaction: () => void; 20 | commitTransaction: () => void; 21 | pullIn: () => Promise<Block>; 22 | pullOut: () => Promise<Block>; 23 | deleteBlock: () => Promise<void>; 24 | setFocus: (targetBlock: Block) => number; 25 | insertAndUpdate: ( 26 | parentId: string, 27 | option: any, 28 | insertIndex: number, 29 | updateOption: any, 30 | ) => Promise<Block>; 31 | deleteAndUpdateWithChildren: (updateOption: any) => Promise<Block>; 32 | setCaretOffset: ( 33 | offset?: number, 34 | isBlur?: boolean, 35 | isCaret?: boolean, 36 | ) => void; 37 | } 38 | 39 | const useManger = ( 40 | blockId: string, 41 | ): [BlockFamily, ManagerFunc & FamilyFunc] => { 42 | const [family, familyFunc] = useFamily(blockId); 43 | const { 44 | blockMap, 45 | block, 46 | blockIndex, 47 | parent, 48 | parentIndex, 49 | grandParent, 50 | page, 51 | childrenIdList, 52 | children, 53 | siblingsIdList, 54 | siblings, 55 | parentsIdList, 56 | parents, 57 | } = family; 58 | let transaction: BlockMap = { ...blockMap }; 59 | const setFocusId = useSetRecoilState(focusState); 60 | 61 | const startTransaction = () => { 62 | transaction = { ...blockMap }; 63 | }; 64 | 65 | const commitTransaction = () => { 66 | familyFunc.setBlockMap(transaction); 67 | }; 68 | 69 | const setBlock = async (id: string, option: any = {}) => { 70 | const { block: updatedBlock } = await updateBlock({ 71 | ...transaction[id], 72 | ...option, 73 | }); 74 | return updatedBlock; 75 | }; 76 | 77 | // const setUpdatedBlock = (id: string, option: any = {}) => { 78 | // transaction = { 79 | // ...transaction, 80 | // [id]: { 81 | // ...transaction[id], 82 | // ...option, 83 | // }, 84 | // }; 85 | // return transaction[id]; 86 | // }; 87 | 88 | const insertNewChild = async ( 89 | option: any = {}, 90 | insertIndex = 0, 91 | ): Promise<Block> => { 92 | const { block: updatedBlock, parent: updatedParent } = await createBlock({ 93 | parentBlockId: block.id, 94 | index: insertIndex, 95 | block: option, 96 | }); 97 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 98 | // setUpdatedBlock(updatedParent.id, updatedParent); 99 | return updatedBlock; 100 | }; 101 | 102 | const insertSibling = async ( 103 | id: string, 104 | insertIndex: number = 0, 105 | ): Promise<Block> => { 106 | const { block: updatedBlock, from, to } = await moveBlock({ 107 | blockId: id, 108 | toId: parent.id, 109 | index: insertIndex, 110 | }); 111 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 112 | // setUpdatedBlock(from.id, from); 113 | // setUpdatedBlock(to.id, to); 114 | return updatedBlock; 115 | }; 116 | 117 | const insertNewSibling = async ( 118 | option: any = {}, 119 | insertIndex = blockIndex + 1, 120 | ): Promise<Block> => { 121 | const { block: newBlock, parent: updatedParent } = await createBlock({ 122 | parentBlockId: parent.id, 123 | index: insertIndex, 124 | block: option, 125 | }); 126 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 127 | // setUpdatedBlock(updatedParent.id, updatedParent); 128 | return newBlock; 129 | }; 130 | 131 | const insertAndUpdate = async ( 132 | parentId: string, 133 | option: any = {}, 134 | insertIndex = blockIndex + 1, 135 | updateOption: any = {}, 136 | ) => { 137 | const { 138 | parent: updatedParent, 139 | block: newBlock, 140 | updated: updatedBlock, 141 | } = await createAndUpdate({ 142 | create: { 143 | parentId, 144 | index: insertIndex, 145 | blockDTO: { ...option }, 146 | }, 147 | update: { ...block, ...updateOption }, 148 | }); 149 | // setUpdatedBlock(updatedParent.id, updatedParent); 150 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 151 | // setUpdatedBlock(newBlock.id, newBlock); 152 | return block; 153 | }; 154 | 155 | const deleteBlock = async () => { 156 | const { parent: updatedBlock } = await deletePageCascade(block.id); 157 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 158 | }; 159 | 160 | const deleteAndUpdateWithChildren = async (updateOption: any) => { 161 | const { 162 | parent: updatedParent, 163 | updated: updatedBlock, 164 | } = await deleteAndUpdate({ 165 | deleteId: block.id, 166 | update: { ...updateOption }, 167 | }); 168 | // setUpdatedBlock(updatedParent.id, updatedParent); 169 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 170 | return updatedBlock; 171 | }; 172 | 173 | const pullIn = async () => { 174 | if (blockIndex) { 175 | await setBlock(block.id, { 176 | ...block, 177 | value: blockRefState[block.id].current.innerText, 178 | }); 179 | const { block: updatedBlock, from, to } = await moveBlock({ 180 | blockId: block.id, 181 | toId: siblingsIdList[blockIndex - 1], 182 | }); 183 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 184 | // setUpdatedBlock(from.id, from); 185 | // setUpdatedBlock(to.id, to); 186 | return updatedBlock; 187 | } 188 | return block; 189 | }; 190 | 191 | const pullOut = async () => { 192 | if (grandParent && grandParent.type !== BlockType.GRID) { 193 | await setBlock(block.id, { 194 | ...block, 195 | value: blockRefState[block.id].current.innerText, 196 | }); 197 | const { block: updatedBlock, from, to } = await moveBlock({ 198 | blockId: block.id, 199 | toId: grandParent.id, 200 | index: parentIndex + 1, 201 | }); 202 | // setUpdatedBlock(updatedBlock.id, updatedBlock); 203 | // setUpdatedBlock(from.id, from); 204 | // setUpdatedBlock(to.id, to); 205 | return updatedBlock; 206 | } 207 | return block; 208 | }; 209 | 210 | const setFocus = (targetBlock: Block) => { 211 | if (!targetBlock) { 212 | return null; 213 | } 214 | const beforeOffset = window.getSelection().focusOffset; 215 | setFocusId(targetBlock.id); 216 | const targetRef = blockRefState[targetBlock.id]; 217 | if (targetRef) { 218 | targetRef.current.focus(); 219 | } 220 | return beforeOffset; 221 | }; 222 | 223 | const setCaretOffset = ( 224 | offset: number = window.getSelection().focusOffset, 225 | isBlur: boolean = true, 226 | isCaret: boolean = true, 227 | ) => { 228 | const sel = window.getSelection(); 229 | const { focusNode: beforeNode } = sel; 230 | const length = (beforeNode as any).length && beforeNode.textContent.length; 231 | if (length) sel.collapse(beforeNode, offset > length ? length : offset); 232 | }; 233 | 234 | return [ 235 | family, 236 | { 237 | ...familyFunc, 238 | insertNewChild, 239 | insertNewSibling, 240 | insertSibling, 241 | setBlock, 242 | startTransaction, 243 | commitTransaction, 244 | pullIn, 245 | pullOut, 246 | deleteBlock, 247 | setFocus, 248 | setCaretOffset, 249 | insertAndUpdate, 250 | deleteAndUpdateWithChildren, 251 | }, 252 | ]; 253 | }; 254 | 255 | export default useManger; 256 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSocket.tsx: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from 'recoil'; 2 | import { blockMapState, pagesState } from '@/stores'; 3 | import { pageIO, pageListIO } from '@/socket'; 4 | import { BlockMap, Page } from '@/schemes'; 5 | import { useEffect } from 'react'; 6 | import { pageUserCountState, lastUpdateState, pageState } from '@/stores/page'; 7 | 8 | const doSomeThing = () => {}; 9 | 10 | const useSocket = () => { 11 | const setBlockMap = useSetRecoilState(blockMapState); 12 | const setPages = useSetRecoilState(pagesState); 13 | const setPageUserCount = useSetRecoilState(pageUserCountState); 14 | const setLastUpdate = useSetRecoilState(lastUpdateState); 15 | const setPage = useSetRecoilState(pageState); 16 | 17 | useEffect(() => { 18 | pageListIO.on( 19 | 'PageListUpdate', 20 | ({ currentPageId, pages }: { currentPageId: string; pages: Page[] }) => { 21 | setPages(pages); 22 | const currentPage = pages.find((page) => page.id === currentPageId); 23 | if (currentPage) { 24 | setPage(currentPage); 25 | } else if (pages?.[0]) { 26 | setPage(pages?.[0]); 27 | } 28 | }, 29 | ); 30 | 31 | pageListIO.on('allUserCount', (count: number) => { 32 | console.log(`allUserCount: ${count}`); 33 | }); 34 | 35 | pageIO.on('connect_error', () => { 36 | setTimeout(() => { 37 | pageIO.connect(); 38 | }, 1000); 39 | }); 40 | 41 | pageIO.on('PageUpdate', (updatedBlockMap: BlockMap) => { 42 | const { focusOffset: beforeOffset, focusNode } = window.getSelection(); 43 | const beforeLength = (focusNode as any)?.length; 44 | if (beforeLength) { 45 | setTimeout(() => { 46 | try { 47 | const sel = window.getSelection(); 48 | const afterLength = (focusNode as any)?.length; 49 | if ( 50 | focusNode !== sel.focusNode || 51 | beforeOffset !== sel.focusOffset 52 | ) { 53 | sel.collapse( 54 | sel.focusNode, 55 | beforeOffset < afterLength ? beforeOffset : afterLength, 56 | ); 57 | } 58 | } catch (e: any) { 59 | doSomeThing(); 60 | } 61 | }); 62 | } 63 | setLastUpdate(new Date()); 64 | setBlockMap(updatedBlockMap); 65 | }); 66 | 67 | pageIO.on('pageUserCount', (count: number) => { 68 | setPageUserCount(count); 69 | }); 70 | }, []); 71 | }; 72 | 73 | export default useSocket; 74 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime classic */ 2 | /** @jsx jsx */ 3 | import { jsx } from '@emotion/react'; 4 | import { Suspense } from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import { RecoilRoot } from 'recoil'; 7 | 8 | import App from '@/App'; 9 | 10 | ReactDOM.render( 11 | <RecoilRoot> 12 | <Suspense fallback={<div />}> 13 | <App /> 14 | </Suspense> 15 | </RecoilRoot>, 16 | document.getElementById('root'), 17 | ); 18 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="react-scripts" /> 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /frontend/src/schemes.ts: -------------------------------------------------------------------------------- 1 | export enum BlockType { 2 | TEXT = 'text', 3 | GRID = 'grid', 4 | COLUMN = 'column', 5 | HEADING1 = 'heading1', 6 | HEADING2 = 'heading2', 7 | HEADING3 = 'heading3', 8 | BULLETED_LIST = 'bulletedlist', 9 | NUMBERED_LIST = 'numberedlist', 10 | QUOTE = 'quote', 11 | PAGE = 'page', 12 | } 13 | 14 | export type IdType = string; 15 | 16 | export interface Block { 17 | id: IdType; 18 | type: BlockType; 19 | value: string; 20 | childIdList: IdType[]; 21 | parentId: IdType | null; 22 | pageId: IdType; 23 | } 24 | 25 | export type BlockMap = { [key: string]: Block }; 26 | export interface BlockFamily { 27 | blockMap: BlockMap; 28 | block: Block; 29 | blockIndex: number; 30 | parent: Block | null; 31 | parentIndex: number | null; 32 | grandParent: Block | null; 33 | page: Page; 34 | childrenIdList: IdType[]; 35 | children: Block[]; 36 | siblingsIdList: IdType[]; 37 | siblings: Block[]; 38 | parentsIdList: IdType[]; 39 | parents: Block[]; 40 | prevSibling: Block; 41 | nextSibling: Block; 42 | prevSiblings: Block[]; 43 | nextSiblings: Block[]; 44 | } 45 | 46 | export interface FamilyFunc { 47 | getNextBlock: () => Block | null; 48 | getPrevBlock: () => Block | null; 49 | setBlockMap: (blockMap: BlockMap) => void; 50 | } 51 | 52 | export interface Page { 53 | id: IdType; 54 | title: string; 55 | rootId: IdType; 56 | } 57 | 58 | export interface User { 59 | id: IdType; 60 | password: string; 61 | ownedPageIdList: Array<IdType>; 62 | editablePageIdList: Array<IdType>; 63 | readablePageIdList: Array<IdType>; 64 | oAuths: Array<OAuth>; 65 | } 66 | 67 | export interface OAuth {} 68 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/socket.ts: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | 3 | export const pageIO = io('/page', { 4 | path: '/socket.io', 5 | transports: ['websocket'], 6 | reconnection: true, 7 | }); 8 | 9 | export const pageListIO = io('/pageList', { 10 | path: '/socket.io', 11 | transports: ['websocket'], 12 | }); 13 | -------------------------------------------------------------------------------- /frontend/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page'; 2 | -------------------------------------------------------------------------------- /frontend/src/stores/page.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | import { Block, BlockMap, Page } from '@/schemes'; 4 | import { readBlockMap, refreshPages } from '@/utils'; 5 | import { MutableRefObject } from 'react'; 6 | 7 | enum StateType { 8 | PAGE_STATE = 'pageState', 9 | BLOCK_STATE = 'blockState', 10 | HOVER_STATE = 'hoverState', 11 | MODAL_STATE = 'modalState', 12 | FOCUS_STATE = 'focusState', 13 | CARET_STATE = 'caretState', 14 | BLOCK_REF_STATE = 'blockRefState', 15 | PAGES_STATE = 'pagesState', 16 | BLOCK_MAP_STATE = 'blockMapState', 17 | STATIC_MENU_TOGGLE_STATE = 'staticMenuToggleState', 18 | HOVERED_MENU_TOGGLE_STATE = 'hoveredMenuToggleState', 19 | SELECTED_PAGE_STATE = 'selectedPageState', 20 | DRAGGING_BLOCK_STATE = 'draggingBlockState', 21 | ALL_USER_COUNT_STATE = 'allusercountstate', 22 | PAGE_USER_COUNT_STATE = 'pageusercountstate', 23 | LAST_UPDATE = 'lastupdate', 24 | } 25 | 26 | export const pagesState = atom({ 27 | key: StateType.PAGES_STATE, 28 | default: refreshPages(), 29 | }); 30 | 31 | export const pageState = atom<Page>({ 32 | key: StateType.PAGE_STATE, 33 | default: (async () => (await refreshPages())[0])(), 34 | }); 35 | 36 | export const blockMapState = atom<BlockMap>({ 37 | key: StateType.BLOCK_MAP_STATE, 38 | default: (async () => { 39 | const page = (await refreshPages())[0]; 40 | const { blockMap } = await readBlockMap(page.id); 41 | return blockMap; 42 | })(), 43 | }); 44 | 45 | export const lastUpdateState = atom({ 46 | key: StateType.LAST_UPDATE, 47 | default: new Date(), 48 | }); 49 | 50 | export const allUserCountState = atom({ 51 | key: StateType.ALL_USER_COUNT_STATE, 52 | default: 0, 53 | }); 54 | 55 | export const pageUserCountState = atom({ 56 | key: StateType.PAGE_USER_COUNT_STATE, 57 | default: 0, 58 | }); 59 | 60 | export const throttleState = { 61 | isThrottle: false, 62 | }; 63 | 64 | export const blockRefState: { [id: string]: MutableRefObject<any> } = {}; 65 | 66 | export const hoverState = atom({ 67 | key: StateType.HOVER_STATE, 68 | default: null, 69 | }); 70 | 71 | export const caretState = atom({ 72 | key: StateType.CARET_STATE, 73 | default: 0, 74 | }); 75 | 76 | export const focusState = atom({ 77 | key: StateType.FOCUS_STATE, 78 | default: null, 79 | }); 80 | 81 | export const modalState = atom({ 82 | key: StateType.MODAL_STATE, 83 | default: { 84 | isOpen: false, 85 | caretOffset: 0, 86 | blockId: '', 87 | top: 0, 88 | left: 0, 89 | }, 90 | }); 91 | 92 | export const staticMenuToggleState = atom({ 93 | key: StateType.STATIC_MENU_TOGGLE_STATE, 94 | default: false, 95 | }); 96 | 97 | export const hoveredMenuToggleState = atom({ 98 | key: StateType.HOVERED_MENU_TOGGLE_STATE, 99 | default: false, 100 | }); 101 | 102 | export const draggingBlockState = atom<Block>({ 103 | key: StateType.DRAGGING_BLOCK_STATE, 104 | default: null, 105 | }); 106 | -------------------------------------------------------------------------------- /frontend/src/utils/blockApis.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockMap } from '@/schemes'; 2 | 3 | import { fetchApi } from '@/utils'; 4 | 5 | const BASE_URL = '/api/blocks'; 6 | 7 | export const createBlock = (param: { 8 | parentBlockId: string; 9 | index?: number; 10 | block?: { type?: string; value?: string }; 11 | }) => 12 | fetchApi< 13 | { parent: Block; block: Block }, 14 | { 15 | block?: { type?: string; value?: string }; 16 | index?: number; 17 | } 18 | >({ 19 | url: `${BASE_URL}/parent-id/${param.parentBlockId}`, 20 | method: 'POST', 21 | defaultReturn: { parent: null, block: null }, 22 | })({ index: param.index, block: param.block }); 23 | 24 | export const readBlockMap = (pageId: string) => 25 | fetchApi<{ blockMap: BlockMap }>({ 26 | url: `${BASE_URL}/page-id/${pageId}`, 27 | method: 'GET', 28 | defaultReturn: { blockMap: {} }, 29 | })(); 30 | 31 | export const updateBlock = (block: Block) => 32 | fetchApi<{ block: Block }, { block: Block }>({ 33 | url: `${BASE_URL}/id/${block.id}`, 34 | method: 'PATCH', 35 | defaultReturn: { block }, 36 | })({ block }); 37 | 38 | export const moveBlock = (param: { 39 | blockId: string; 40 | toId: string; 41 | index?: number; 42 | }) => 43 | fetchApi<{ block: Block; from: Block; to: Block }, { index?: number }>({ 44 | url: `${BASE_URL}/id/${param.blockId}/to/${param.toId}`, 45 | method: 'PATCH', 46 | defaultReturn: { block: null, from: null, to: null }, 47 | })({ index: param.index }); 48 | 49 | export const deletePageCascade = (blockId: string) => 50 | fetchApi<{ parent: Block }>({ 51 | url: `${BASE_URL}/id/${blockId}`, 52 | method: 'DELETE', 53 | defaultReturn: { parent: null }, 54 | })(); 55 | 56 | type CreateAndUpdateBody = { 57 | create: { 58 | parentId: string; 59 | index?: number; 60 | blockDTO?: { type?: string; value?: string }; 61 | }; 62 | update: Block | null; 63 | }; 64 | 65 | export const createAndUpdate = (param: CreateAndUpdateBody) => 66 | fetchApi< 67 | { parent: Block; block: Block; updated: Block }, 68 | CreateAndUpdateBody 69 | >({ 70 | url: `${BASE_URL}/create-and-update`, 71 | method: 'PATCH', 72 | defaultReturn: { parent: null, block: null, updated: null }, 73 | })(param); 74 | 75 | type DeleteAndUpdateBody = { 76 | deleteId: string; 77 | update: Block | null; 78 | }; 79 | 80 | export const deleteAndUpdate = (param: DeleteAndUpdateBody) => 81 | fetchApi<{ parent: Block; updated: Block }, DeleteAndUpdateBody>({ 82 | url: `${BASE_URL}/delete-and-update`, 83 | method: 'PATCH', 84 | defaultReturn: { parent: null, updated: null }, 85 | })(param); 86 | -------------------------------------------------------------------------------- /frontend/src/utils/blockContent.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxRuntime classic */ 3 | import { jsx, css } from '@emotion/react'; 4 | 5 | import { Block, BlockType } from '@/schemes'; 6 | 7 | export const regexType: { [key: string]: RegExp } = { 8 | heading3: /^###/gm, 9 | heading2: /^##/gm, 10 | heading1: /^#/gm, 11 | bulletedlist: /^[-,+]/gm, 12 | numberedlist: /^\d+\./gm, 13 | quote: /^\|/gm, 14 | }; 15 | 16 | export const validateType = (content: string): BlockType => { 17 | if (content === '#') return BlockType.HEADING1; 18 | if (content === '##') return BlockType.HEADING2; 19 | if (content === '###') return BlockType.HEADING3; 20 | if (content === '-' || content === '+') return BlockType.BULLETED_LIST; 21 | if (/^\d+\./gm.test(content)) return BlockType.NUMBERED_LIST; 22 | if (content === '|') return BlockType.QUOTE; 23 | return null; 24 | }; 25 | 26 | const divCSS = css` 27 | margin: 0px 4px; 28 | font-size: 18px; 29 | color: inherit; 30 | height: 100%; 31 | `; 32 | 33 | export const listBlockType = (block: Block, idx: number) => { 34 | if (block.type === BlockType.NUMBERED_LIST) { 35 | return <span css={divCSS}>{`${idx}. `}</span>; 36 | } 37 | return listComponent[block.type]; 38 | }; 39 | 40 | export const listComponent: { [key: string]: any } = { 41 | bulletedlist: <div css={divCSS}>•</div>, 42 | quote: <div css={divCSS}>▕</div>, 43 | }; 44 | 45 | export const fontSize: { [key: string]: string } = { 46 | heading1: 'xx-large', 47 | heading2: 'x-large', 48 | heading3: 'large', 49 | }; 50 | 51 | export const placeHolder: { [key: string]: string } = { 52 | text: "Type '/' for commands", 53 | heading1: 'Heading 1', 54 | heading2: 'Heading 2', 55 | heading3: 'Heading 3', 56 | quote: 'Empty quote', 57 | }; 58 | -------------------------------------------------------------------------------- /frontend/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = (func: any, delay: number) => { 2 | let timer: any; 3 | return function rtn(this: any, ...args: any[]) { 4 | const context = this; 5 | if (timer) clearTimeout(timer); 6 | timer = setTimeout(() => func.apply(context, args), delay); 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/utils/fetchApi.ts: -------------------------------------------------------------------------------- 1 | import axios, { Method } from 'axios'; 2 | 3 | type ErrorType = { error?: 1 | true; message?: string }; 4 | 5 | const errorHandler = (e: Error) => { 6 | console.error(e); 7 | }; 8 | 9 | export const fetchApi = <T, S = null>(param: { 10 | url: string; 11 | method: Method; 12 | defaultReturn: T; 13 | }) => async (paramData?: S): Promise<T> => { 14 | try { 15 | const data = ( 16 | await axios({ 17 | url: param.url, 18 | method: param.method, 19 | data: paramData, 20 | }) 21 | ).data as T & ErrorType; 22 | if (data?.error) { 23 | throw new Error(data.message); 24 | } 25 | return data; 26 | } catch (e) { 27 | errorHandler(e); 28 | return param.defaultReturn; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/utils/fetchDummyData.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockType, IdType, Page } from '@/schemes'; 2 | 3 | export const fetchDummyData = (): Promise<{ 4 | page: Page; 5 | blockMap: { [key: string]: Block }; 6 | }> => 7 | new Promise((resolve) => { 8 | const page = { 9 | id: '1', 10 | title: 'Page 01', 11 | rootId: '0', 12 | }; 13 | 14 | const blockMap: { [key: string]: Block } = { 15 | 0: { 16 | id: '0', 17 | type: BlockType.PAGE, 18 | value: '', 19 | pageId: '1', 20 | parentId: null, 21 | childIdList: ['1', '2', '3'], 22 | }, 23 | 1: { 24 | id: '1', 25 | type: BlockType.TEXT, 26 | value: 'Hello, Bootion!!', 27 | pageId: '1', 28 | parentId: '0', 29 | childIdList: [], 30 | }, 31 | 2: { 32 | id: '2', 33 | type: BlockType.TEXT, 34 | value: 'Parent Block', 35 | pageId: '1', 36 | parentId: '0', 37 | childIdList: ['21', '22'], 38 | }, 39 | 21: { 40 | id: '21', 41 | type: BlockType.TEXT, 42 | value: 'Child Block 01', 43 | pageId: '1', 44 | parentId: '2', 45 | childIdList: ['211', '212'], 46 | }, 47 | 211: { 48 | id: '211', 49 | type: BlockType.TEXT, 50 | value: 'Grandson Block 01', 51 | pageId: '1', 52 | parentId: '21', 53 | childIdList: [], 54 | }, 55 | 212: { 56 | id: '212', 57 | type: BlockType.TEXT, 58 | value: 'Grandson Block 02', 59 | pageId: '1', 60 | parentId: '21', 61 | childIdList: [], 62 | }, 63 | 22: { 64 | id: '22', 65 | type: BlockType.TEXT, 66 | value: 'Child Block 02', 67 | pageId: '1', 68 | parentId: '2', 69 | childIdList: [], 70 | }, 71 | 3: { 72 | id: '3', 73 | type: BlockType.GRID, 74 | value: 'Grid Block', 75 | pageId: '1', 76 | parentId: '0', 77 | childIdList: ['31', '32', '33', '34'], 78 | }, 79 | 31: { 80 | id: '31', 81 | type: BlockType.COLUMN, 82 | value: 'Column Block 01', 83 | pageId: '1', 84 | parentId: '3', 85 | childIdList: ['311', '312'], 86 | }, 87 | 311: { 88 | id: '311', 89 | type: BlockType.TEXT, 90 | value: 'Row 01 - Col 01', 91 | pageId: '1', 92 | parentId: '31', 93 | childIdList: [], 94 | }, 95 | 312: { 96 | id: '312', 97 | type: BlockType.TEXT, 98 | value: 'Row 02 - Col 01', 99 | pageId: '1', 100 | parentId: '31', 101 | childIdList: [], 102 | }, 103 | 32: { 104 | id: '32', 105 | type: BlockType.COLUMN, 106 | value: 'Column Block 02', 107 | pageId: '1', 108 | parentId: '3', 109 | childIdList: ['321', '322', '323'], 110 | }, 111 | 321: { 112 | id: '321', 113 | type: BlockType.TEXT, 114 | value: 'Row 01 - Col 02', 115 | pageId: '1', 116 | parentId: '32', 117 | childIdList: [], 118 | }, 119 | 322: { 120 | id: '322', 121 | type: BlockType.TEXT, 122 | value: 'Row 02 - Col 02', 123 | pageId: '1', 124 | parentId: '32', 125 | childIdList: [], 126 | }, 127 | 323: { 128 | id: '323', 129 | type: BlockType.TEXT, 130 | value: 'Row 03 - Col 02', 131 | pageId: '1', 132 | parentId: '32', 133 | childIdList: [], 134 | }, 135 | 33: { 136 | id: '33', 137 | type: BlockType.COLUMN, 138 | value: 'Column Block 03', 139 | pageId: '1', 140 | parentId: '3', 141 | childIdList: ['331'], 142 | }, 143 | 331: { 144 | id: '331', 145 | type: BlockType.TEXT, 146 | value: 'Row 01 - Col 03', 147 | pageId: '1', 148 | parentId: '33', 149 | childIdList: [], 150 | }, 151 | 34: { 152 | id: '34', 153 | type: BlockType.COLUMN, 154 | value: 'Column Block 04', 155 | pageId: '1', 156 | parentId: '3', 157 | childIdList: ['341'], 158 | }, 159 | 341: { 160 | id: '341', 161 | type: BlockType.TEXT, 162 | value: 'Row 01 - Col 04', 163 | pageId: '1', 164 | parentId: '34', 165 | childIdList: [], 166 | }, 167 | }; 168 | const data = { page, blockMap }; 169 | setTimeout(() => resolve(data), 500); 170 | }); 171 | -------------------------------------------------------------------------------- /frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { fetchDummyData } from './fetchDummyData'; 2 | export * from './blockContent'; 3 | export * from './debounce'; 4 | export * from './fetchApi'; 5 | export * from './blockApis'; 6 | export * from './pageApis'; 7 | export * from './time'; 8 | -------------------------------------------------------------------------------- /frontend/src/utils/pageApis.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@/schemes'; 2 | 3 | import { fetchApi } from '@/utils'; 4 | 5 | const BASE_URL = '/api/pages'; 6 | 7 | export const createPage = fetchApi< 8 | { page: Page; pages: Page[] }, 9 | { page: Page } 10 | >({ 11 | url: `${BASE_URL}`, 12 | method: 'POST', 13 | defaultReturn: { page: null, pages: [] }, 14 | }); 15 | 16 | export const readPage = (pageId: string) => 17 | fetchApi<{ page: Page }>({ 18 | url: `${BASE_URL}/id/${pageId}`, 19 | method: 'GET', 20 | defaultReturn: { page: null }, 21 | })(); 22 | 23 | export const readPages = fetchApi<{ pages: Page[] }>({ 24 | url: BASE_URL, 25 | method: 'GET', 26 | defaultReturn: { pages: [] }, 27 | }); 28 | 29 | export const updatePage = (page: Page) => 30 | fetchApi<{ page: Page }, { page: Page }>({ 31 | url: `${BASE_URL}/id/${page.id}`, 32 | method: 'PATCH', 33 | defaultReturn: { page }, 34 | })({ page }); 35 | 36 | export const deletePage = (pageId: string) => 37 | fetchApi<{ pages: Page[] }>({ 38 | url: `${BASE_URL}/id/${pageId}`, 39 | method: 'DELETE', 40 | defaultReturn: { pages: [] }, 41 | })(); 42 | 43 | export const refreshPages = async () => { 44 | let { pages } = await readPages(); 45 | if (!pages.length) { 46 | ({ pages } = await createPage()); 47 | } 48 | return pages; 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export const timeSince = (date: Date) => { 2 | const seconds = Math.floor((+new Date() - +date) / 1000); 3 | 4 | let interval = seconds / 31536000; 5 | 6 | if (interval > 1) { 7 | return `${Math.floor(interval)} years`; 8 | } 9 | interval = seconds / 2592000; 10 | if (interval > 1) { 11 | return `${Math.floor(interval)} months`; 12 | } 13 | interval = seconds / 86400; 14 | if (interval > 1) { 15 | return `${Math.floor(interval)} days`; 16 | } 17 | interval = seconds / 3600; 18 | if (interval > 1) { 19 | return `${Math.floor(interval)} hours`; 20 | } 21 | interval = seconds / 60; 22 | if (interval > 1) { 23 | return `${Math.floor(interval)} minutes`; 24 | } 25 | return `${Math.floor(seconds)} seconds`; 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "strict": true, 13 | "strictNullChecks": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "types": [ 23 | "@emotion/react/types/css-prop" 24 | ], 25 | "noImplicitAny": true, 26 | "removeComments": true, 27 | "allowSyntheticDefaultImports": true 28 | }, 29 | "include": [ 30 | "src" 31 | ], 32 | "extends": "./tsconfig.paths.json" 33 | } 34 | -------------------------------------------------------------------------------- /frontend/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"], 6 | "@assets/*": ["src/assets/*"], 7 | "@utils/*": ["src/utils/*"], 8 | "@stores/*": ["src/stores/*"], 9 | "@components/*": ["src/components/*"], 10 | "@atoms/*": ["src/components/atoms/*"], 11 | "@molecules/*": ["src/components/molecules/*"], 12 | "@organisms/*": ["src/components/organisms/*"], 13 | "@pages/*": ["src/components/pages/*"] 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------