├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 기능-구현.md │ └── 버그-리포트.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── boolock-dev-cicd.yml │ ├── check-branch.yml │ └── deploy-storybook.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── settings.json └── storybook.code-snippets ├── Dockerfile.base ├── LICENSE ├── README.md ├── apps ├── client │ ├── .storybook │ │ ├── decorators │ │ │ ├── HelmetProdiver.tsx │ │ │ ├── MemoryRouterProvider.tsx │ │ │ └── QueryProvider.tsx │ │ ├── main.js │ │ └── preview.tsx │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── fonts │ │ │ └── SUIT-Variable.woff2 │ │ ├── icons │ │ │ └── close.svg │ │ ├── images │ │ │ ├── boolock_logo.png │ │ │ └── boolock_thumnail.png │ │ └── robots.txt │ ├── src │ │ ├── app │ │ │ ├── App.tsx │ │ │ ├── index.css │ │ │ └── main.tsx │ │ ├── core │ │ │ ├── customCategory.ts │ │ │ ├── customConstantProvider.ts │ │ │ ├── customFieldImageButton.ts │ │ │ ├── customFieldLabelSerializable.ts │ │ │ ├── customFieldTextInput.ts │ │ │ ├── customOptionFieldLabel.ts │ │ │ ├── customRenderInfo.ts │ │ │ ├── customRenderer.ts │ │ │ ├── customTagFieldLabel.ts │ │ │ ├── customTooltip.ts │ │ │ ├── customTrashcan.ts │ │ │ ├── customZoomControls.ts │ │ │ ├── dom.ts │ │ │ ├── fixedFlyout.ts │ │ │ ├── register.ts │ │ │ ├── styleFlyout.ts │ │ │ └── tabbedToolbox.ts │ │ ├── entities │ │ │ ├── home │ │ │ │ ├── HoveredEmptyWorkspace │ │ │ │ │ ├── HoveredEmptyWorkspace.stories.tsx │ │ │ │ │ └── HoveredEmptyWorkspace.tsx │ │ │ │ ├── NotHoveredEmptyWorkspace │ │ │ │ │ ├── NotHoveredEmptyWorkspace.stories.tsx │ │ │ │ │ └── NotHoveredEmptyWorkspace.tsx │ │ │ │ ├── WorkspaceAddBtn │ │ │ │ │ ├── WorkspaceAddBtn.stories.tsx │ │ │ │ │ └── WorkspaceAddBtn.tsx │ │ │ │ ├── WorkspaceItem │ │ │ │ │ ├── WorkspaceItem.stories.tsx │ │ │ │ │ └── WorkspaceItem.tsx │ │ │ │ ├── WorkspaceLoadError │ │ │ │ │ ├── WorkspaceLoadError.stories.tsx │ │ │ │ │ └── WorkspaceLoadError.tsx │ │ │ │ └── WorkspaceSampleButton │ │ │ │ │ ├── WorkspaceSampleButton.stories.tsx │ │ │ │ │ └── WorkspaceSampleButton.tsx │ │ │ ├── index.ts │ │ │ └── workspace │ │ │ │ ├── CodeExportButton │ │ │ │ ├── CodeExportButton.stories.tsx │ │ │ │ └── CodeExportButton.tsx │ │ │ │ ├── CssCategoryButton │ │ │ │ ├── CssCategoryButton.stories.tsx │ │ │ │ └── CssCategoryButton.tsx │ │ │ │ ├── CssOptionItem │ │ │ │ ├── CssOptionItem.stories.tsx │ │ │ │ └── CssOptionItem.tsx │ │ │ │ ├── CssTooltip │ │ │ │ ├── CssTooltip.stories.tsx │ │ │ │ └── CssTooltip.tsx │ │ │ │ ├── ImageTagModalButton │ │ │ │ ├── ImageTagModalButton.stories.tsx │ │ │ │ └── ImageTagModalButton.tsx │ │ │ │ ├── ImageTagModalHeader │ │ │ │ ├── ImageTagModalHeader.stories.tsx │ │ │ │ └── ImageTagModalHeader.tsx │ │ │ │ ├── ImageTagModalImg │ │ │ │ ├── ImageTagModalImg.stories.tsx │ │ │ │ └── ImageTagModalImg.tsx │ │ │ │ ├── ImageTagModalList │ │ │ │ ├── ImageTagModalList.stories.tsx │ │ │ │ └── ImageTagModalList.tsx │ │ │ │ ├── ImageTagModalListItem │ │ │ │ ├── ImageTagModalListItem.stories.tsx │ │ │ │ └── ImageTagModalListItem.tsx │ │ │ │ ├── RedoButton │ │ │ │ ├── RedoButton.stories.tsx │ │ │ │ └── RedoButton.tsx │ │ │ │ ├── RenderResetCssTooltip │ │ │ │ └── RenderResetCssTooltip.tsx │ │ │ │ ├── SaveButton │ │ │ │ ├── SaveButton.stories.tsx │ │ │ │ └── SaveButton.tsx │ │ │ │ ├── UndoButton │ │ │ │ ├── UndoButton.stories.tsx │ │ │ │ └── UndoButton.tsx │ │ │ │ └── WorkspaceNameInput │ │ │ │ ├── WorkspaceNameInput.stories.tsx │ │ │ │ └── WorkspaceNameInput.tsx │ │ ├── pages │ │ │ ├── ErrorPage │ │ │ │ ├── ErrorPage.stories.tsx │ │ │ │ └── ErrorPage.tsx │ │ │ ├── HomePage │ │ │ │ ├── HomePage.stories.tsx │ │ │ │ └── HomePage.tsx │ │ │ ├── NotFound │ │ │ │ ├── NotFound.stories.tsx │ │ │ │ └── NotFound.tsx │ │ │ ├── Workspacepage │ │ │ │ ├── WorkspacePage.stories.tsx │ │ │ │ └── WorkspacePage.tsx │ │ │ └── index.ts │ │ ├── shared │ │ │ ├── api │ │ │ │ ├── axiosInstance.ts │ │ │ │ ├── index.ts │ │ │ │ └── workspaceApi.ts │ │ │ ├── assets │ │ │ │ ├── arrow_down.svg │ │ │ │ ├── arrow_left.svg │ │ │ │ ├── arrow_right.svg │ │ │ │ ├── arrow_up.svg │ │ │ │ ├── boolock_logo_black.svg │ │ │ │ ├── boolock_logo_white.svg │ │ │ │ ├── check.svg │ │ │ │ ├── close.svg │ │ │ │ ├── code_copy.svg │ │ │ │ ├── css_class_delete_icon.svg │ │ │ │ ├── minus_border.svg │ │ │ │ ├── picture_icon.svg │ │ │ │ ├── plus.svg │ │ │ │ ├── plus_border.svg │ │ │ │ ├── plus_green.svg │ │ │ │ ├── question.svg │ │ │ │ ├── spinner.svg │ │ │ │ ├── table_chart.svg │ │ │ │ ├── tag_container.svg │ │ │ │ ├── tag_etc.svg │ │ │ │ ├── tag_form.svg │ │ │ │ ├── tag_link.svg │ │ │ │ ├── tag_list.svg │ │ │ │ ├── tag_text.svg │ │ │ │ ├── trash.svg │ │ │ │ └── x_icon.svg │ │ │ ├── blockly │ │ │ │ ├── calculateBlockLength.ts │ │ │ │ ├── categoryColours.ts │ │ │ │ ├── createCssClassBlock.ts │ │ │ │ ├── cssCodeGenerator.ts │ │ │ │ ├── cssStyleToolboxConfig.ts │ │ │ │ ├── defineBlocks.ts │ │ │ │ ├── findBlockStartLine.ts │ │ │ │ ├── htmlBlockContents.ts │ │ │ │ ├── htmlCodeGenerator.ts │ │ │ │ ├── htmlTagToolboxConfig.ts │ │ │ │ ├── index.ts │ │ │ │ ├── initBlocks.ts │ │ │ │ ├── initTheme.ts │ │ │ │ └── tabConfig.ts │ │ │ ├── code-highlighter │ │ │ │ ├── components │ │ │ │ │ ├── CodeContent │ │ │ │ │ │ ├── CodeContent.stories.tsx │ │ │ │ │ │ └── CodeContent.tsx │ │ │ │ │ ├── CodeViewer │ │ │ │ │ │ ├── CodeViewer.stories.tsx │ │ │ │ │ │ └── CodeViewer.tsx │ │ │ │ │ └── LineNumbers │ │ │ │ │ │ ├── LineNumber.stories.tsx │ │ │ │ │ │ └── LineNumbers.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── styles │ │ │ │ │ └── CodeViewer.module.css │ │ │ │ └── utils │ │ │ │ │ ├── parseHighlightCss.ts │ │ │ │ │ └── parseHighlightHtml.ts │ │ │ ├── hooks │ │ │ │ ├── css │ │ │ │ │ ├── useCssOptionItem.ts │ │ │ │ │ ├── useCssOptions.ts │ │ │ │ │ ├── useCssTooltip.ts │ │ │ │ │ └── useWindowSize.ts │ │ │ │ ├── index.ts │ │ │ │ ├── queries │ │ │ │ │ ├── useCreateWorkspace.ts │ │ │ │ │ ├── useDeleteImage.ts │ │ │ │ │ ├── useDeleteWorkspace.ts │ │ │ │ │ ├── useGetWorkspace.ts │ │ │ │ │ ├── useGetWorkspaceList.ts │ │ │ │ │ ├── usePostImage.ts │ │ │ │ │ ├── useSaveWorkspace.ts │ │ │ │ │ └── useUpdateWorkspaceName.ts │ │ │ │ ├── query-key │ │ │ │ │ └── workspaceKeys.ts │ │ │ │ ├── useInfiniteScroll.ts │ │ │ │ └── usePreventLeaveWorkspacePage.ts │ │ │ ├── index.ts │ │ │ ├── lib │ │ │ │ └── example │ │ │ ├── store │ │ │ │ ├── index.ts │ │ │ │ ├── useBlocklyWorkspaceStore.ts │ │ │ │ ├── useClassBlockStore.ts │ │ │ │ ├── useCoachMarkStore.ts │ │ │ │ ├── useCssPropsStore.ts │ │ │ │ ├── useCssTooptipStore.ts │ │ │ │ ├── useIframeStore.ts │ │ │ │ ├── useImageModalStore.ts │ │ │ │ ├── useLoadingStore.ts │ │ │ │ ├── useModalStore.ts │ │ │ │ ├── useResetCssStore.ts │ │ │ │ ├── useWorkspaceChangeStatusStore.ts │ │ │ │ └── useWorkspaceStore.ts │ │ │ ├── types │ │ │ │ ├── blockType.ts │ │ │ │ ├── cssCategoryType.ts │ │ │ │ ├── extendedType.ts │ │ │ │ ├── imageTagType.ts │ │ │ │ ├── index.ts │ │ │ │ ├── modalButtonType.ts │ │ │ │ ├── styleToolboxType.ts │ │ │ │ ├── tabType.ts │ │ │ │ └── workspaceType.ts │ │ │ ├── ui │ │ │ │ ├── button │ │ │ │ │ ├── CircleButton.stories.tsx │ │ │ │ │ ├── CircleButton.tsx │ │ │ │ │ ├── SquareButton.stories.tsx │ │ │ │ │ └── SquareButton.tsx │ │ │ │ ├── error │ │ │ │ │ ├── ErrorContent.stories.tsx │ │ │ │ │ └── ErrorContent.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── loading │ │ │ │ │ ├── Loading.stories.tsx │ │ │ │ │ ├── Loading.tsx │ │ │ │ │ ├── Spinner.stories.tsx │ │ │ │ │ └── Spinner.tsx │ │ │ │ ├── logo │ │ │ │ │ ├── Logo.stories.tsx │ │ │ │ │ └── Logo.tsx │ │ │ │ ├── modal │ │ │ │ │ ├── ModalConfirm.stories.tsx │ │ │ │ │ └── ModalConfirm.tsx │ │ │ │ ├── select │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ └── Select.tsx │ │ │ │ ├── skeleton │ │ │ │ │ ├── SkeletonWorkspace.stories.tsx │ │ │ │ │ ├── SkeletonWorkspace.tsx │ │ │ │ │ ├── SkeletonWorkspaceList.stories.tsx │ │ │ │ │ └── SkeletonWorkspaceList.tsx │ │ │ │ └── toast │ │ │ │ │ ├── ToasterWithMax.stories.tsx │ │ │ │ │ └── ToasterWithMax.tsx │ │ │ └── utils │ │ │ │ ├── boolockConstants.ts │ │ │ │ ├── capturePreview.ts │ │ │ │ ├── categoryIcons.ts │ │ │ │ ├── coachMarkContent.tsx │ │ │ │ ├── cssCategoryList.ts │ │ │ │ ├── cssClassName.ts │ │ │ │ ├── dateFormat.ts │ │ │ │ ├── debounce.ts │ │ │ │ ├── exportPreviewHtml.ts │ │ │ │ ├── iframeErrorMessage.ts │ │ │ │ ├── index.ts │ │ │ │ ├── resetCss.ts │ │ │ │ ├── spinnerStyle.ts │ │ │ │ ├── tags.ts │ │ │ │ ├── typeGuard.ts │ │ │ │ └── userId.ts │ │ ├── svg.d.ts │ │ ├── vite-env.d.ts │ │ └── widgets │ │ │ ├── home │ │ │ ├── Banner │ │ │ │ ├── Banner.stories.tsx │ │ │ │ └── Banner.tsx │ │ │ ├── EmptyWorkspace │ │ │ │ ├── EmptyWorkspace.stories.tsx │ │ │ │ └── EmptyWorkspace.tsx │ │ │ ├── HomeHeader │ │ │ │ ├── HomeHeader.stories.tsx │ │ │ │ └── HomeHeader.tsx │ │ │ ├── WorkspaceContainer │ │ │ │ ├── WorkspaceContainer.stories.tsx │ │ │ │ └── WorkspaceContainer.tsx │ │ │ ├── WorkspaceGrid │ │ │ │ ├── WorkspaceGrid.stories.tsx │ │ │ │ └── WorkspaceGrid.tsx │ │ │ ├── WorkspaceHeader │ │ │ │ ├── WorkspaceHeader.stories.tsx │ │ │ │ └── WorkspaceHeader.tsx │ │ │ ├── WorkspaceList │ │ │ │ ├── WorkspaceList.stories.tsx │ │ │ │ └── WorkspaceList.tsx │ │ │ └── WorkspaceModal │ │ │ │ ├── WorkspaceModal.stories.tsx │ │ │ │ └── WorkspaceModal.tsx │ │ │ ├── index.ts │ │ │ └── workspace │ │ │ ├── CoachMark │ │ │ ├── CoachMark.stories.tsx │ │ │ └── CoachMark.tsx │ │ │ ├── ImageTagModal │ │ │ ├── ImageTagModal.stories.tsx │ │ │ └── ImageTagModal.tsx │ │ │ ├── PreviewBox │ │ │ ├── PreviewBox.stories.tsx │ │ │ └── PreviewBox.tsx │ │ │ ├── WorkspaceContent │ │ │ ├── WorkspaceContent.stories.tsx │ │ │ └── WorkspaceContent.tsx │ │ │ ├── WorkspaceHeaderButtons │ │ │ ├── WorkspaceHeaderButtons.stories.tsx │ │ │ └── WorkspaceHeaderButtons.tsx │ │ │ ├── WorkspacePageHeader │ │ │ ├── WorkspacePageHeader.stories.tsx │ │ │ └── WorkspacePageHeader.tsx │ │ │ └── css │ │ │ ├── CssCategoryBar │ │ │ ├── CssCategoryBar.stories.tsx │ │ │ └── CssCategoryBar.tsx │ │ │ ├── CssOptionItemList │ │ │ ├── CssOptionItemList.stories.tsx │ │ │ └── CssOptionItemList.tsx │ │ │ ├── CssPropsSelectBox │ │ │ ├── CssPropsSelectBox.stories.tsx │ │ │ └── CssPropsSelectBox.tsx │ │ │ └── CssPropsSelectBoxHeader │ │ │ ├── CssPropsSelectBoxHeader.stories.tsx │ │ │ └── CssPropsSelectBoxHeader.tsx │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts └── server │ ├── Dockerfile │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ ├── app.ts │ ├── config │ │ ├── dbConnection.ts │ │ └── s3.ts │ ├── controllers │ │ ├── index.ts │ │ └── workspaceController.ts │ ├── docs │ │ ├── swagger.ts │ │ └── swaggerAutogen.ts │ ├── index.ts │ ├── middlewares │ │ └── errorMiddleware.ts │ ├── models │ │ ├── index.ts │ │ └── workspaceModel.ts │ ├── routes │ │ └── v1 │ │ │ ├── index.ts │ │ │ └── workspaceRoute.ts │ ├── services │ │ ├── index.ts │ │ ├── utils │ │ │ ├── generateCssList.ts │ │ │ └── generateTotalCssPropertyObj.ts │ │ └── workspaceService.ts │ ├── types │ │ └── workspaceType.ts │ └── utils │ │ ├── asyncWrapper.ts │ │ ├── constants.ts │ │ └── customError.ts │ ├── tests │ └── example.ts │ └── tsconfig.json ├── docker-compose.yml ├── package.json ├── packages ├── eslint │ ├── index.js │ └── package.json └── tsconfig │ ├── package.json │ ├── tsconfig.app.json │ └── tsconfig.node.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── scripts └── check-branch-name.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist 6 | **/node_modules 7 | *.log 8 | **/Dockerfile -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 모든 브랜치에 대해 기본 리뷰어 설정 2 | * @boostcampwm-2024/Web31 3 | 4 | # chore와 hotfix 브랜치에 대해서는 리뷰어를 지정하지 않음 5 | chore_* 6 | hotfix_* 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/기능-구현.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기능 구현 3 | about: 기능 구현 시 생성하는 이슈 4 | title: '' 5 | labels: feat 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## [epicNum - storyNum] 스토리명 11 | - [ ] 태스크 내용 12 | 13 | ## 📝 Todo 14 | - [ ] 작업 할 내용 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/버그-리포트.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 리포트 3 | about: 버그 발생 시 생성 이슈 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## [epicNum - storyNum] 스토리명 11 | 12 | - [ ] 태스크 내용 13 | 14 | ## 🐞 Bug 10 | - 작업 내용 요약1 11 | - 작업 내용 요약2 12 | 13 | ## 😎 Description (변경사항) 14 | 15 | ### 작업 내용 요약1 16 | - 작업 내용 설명 17 | - 작업 내용 설명 18 | ### 작업 내용 요약2 19 | - 작업 내용 설명 20 | - 작업 내용 설명 21 | 22 | ## 🔥 Trouble Shooting (해결된 문제 및 해결 과정) 23 | 24 | ## 🤔 Open Problem (미해결된 문제 혹은 고민사항) 25 | -------------------------------------------------------------------------------- /.github/workflows/check-branch.yml: -------------------------------------------------------------------------------- 1 | name: Check Branch Name and Prevent Main Merge 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | - main 8 | 9 | jobs: 10 | check-branch-name: 11 | runs-on: ubuntu-latest 12 | if: github.event.pull_request.base.ref == 'dev' 13 | steps: 14 | - name: Extract Source Branch Name 15 | run: | 16 | branch_name=$(jq -r .pull_request.head.ref "$GITHUB_EVENT_PATH") 17 | echo "Source Branch: $branch_name" 18 | 19 | if [[ ! $branch_name =~ ^(feat/[0-9]+|bug/[0-9]+|refactor/[0-9]+|hotfix_[0-9]{4}|chore_[0-9]{4}|gh-pages)$ ]]; then 20 | echo "Error: Branch name must follow the pattern 'feat/[number]', 'bug/[number]', 'hotfix_mmdd', 'chore_mmdd', or 'gh-pages'." 21 | exit 1 22 | fi 23 | 24 | prevent-main-merge: 25 | runs-on: ubuntu-latest 26 | if: github.event.pull_request.base.ref == 'main' 27 | steps: 28 | - name: Prevent Merge to Main 29 | run: | 30 | base_branch=$(jq -r .pull_request.base.ref "$GITHUB_EVENT_PATH") 31 | echo "Base Branch: $base_branch" 32 | 33 | if [[ $base_branch == "main" ]]; then 34 | echo "Error: Pull requests to the main branch are not allowed." 35 | exit 1 36 | fi 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy-storybook.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storybook to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | pull_request: 8 | branches: 9 | - dev 10 | 11 | jobs: 12 | build-and-deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Install pnpm 20 | run: npm install -g pnpm 21 | 22 | - name: Install dependencies 23 | run: pnpm install 24 | - name: Set FE .env 25 | run: | 26 | echo "VITE_SERVER_URL=http://localhost:3000" > apps/client/.env 27 | echo "VITE_STATIC_STORAGE_URL=${{ secrets.VITE_STATIC_STORAGE_URL }}" >> apps/client/.env 28 | 29 | - name: Build Storybook 30 | run: pnpm --filter client run build-storybook 31 | 32 | - name: Deploy to GitHub Pages 33 | uses: peaceiris/actions-gh-pages@v3 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: ./apps/client/storybook-static 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # pnpm modules and cache 2 | .pnpm-store/ 3 | 4 | # Node modules directory (managed by pnpm) 5 | node_modules/ 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | out/ 11 | 12 | # Temporary files 13 | .temp/ 14 | .cache/ 15 | 16 | # Logs 17 | npm-debug.log* 18 | pnpm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Dependency graph 23 | .pnp.* 24 | 25 | # Local environment files 26 | .env 27 | .env.*.local 28 | .env.local 29 | 30 | # IDE files 31 | .idea/ 32 | *.sublime-project 33 | *.sublime-workspace 34 | 35 | # OS-specific files 36 | .DS_Store 37 | Thumbs.db 38 | 39 | *storybook.log 40 | 41 | swagger-output.json 42 | 43 | # Ignore Storybook build folder 44 | storybook-static/ 45 | 46 | ssl/ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | BLUE='\033[0;34m' 7 | NC='\033[0m' 8 | 9 | commit_msg=$(head -n1 "$1") 10 | 11 | # 허용된 커밋 메시지 접두사 패턴 12 | # allowed_patterns="^(Merge|✨ feat: |🐛 fix: |💄 design: |🎨 style: |🔨 refactor: |☔️ test: |📝 docs: |🙀 chore: |💬 comment: |🚚 rename: |🔥 remove: |🚨 !HOTFIX!: |🎸 etc: )" 13 | # if ! echo "$commit_msg" | grep -qE "$allowed_patterns"; then 14 | # printf "${RED}❌ 잘못된 커밋 메시지 형식입니다.${NC}\n" 15 | # printf "${BLUE}커밋 메시지는 다음 중 하나로 시작해야 합니다:${NC}\n" 16 | # printf "${BLUE}✨ feat: - 새로운 기능 추가${NC}\n" 17 | # printf "${BLUE}🐛 fix: - 버그 수정${NC}\n" 18 | # printf "${BLUE}💄 design: - ui/ux 디자인 변경(css)${NC}\n" 19 | # printf "${BLUE}🎨 style: - 코드의 형식 및 스타일 개선${NC}\n" 20 | # printf "${BLUE}🔨 refactor: - 코드 리팩토링${NC}\n" 21 | # printf "${BLUE}☔️ test: - 테스트 추가 및 테스트 리팩토링${NC}\n" 22 | # printf "${BLUE}📝 docs: - 문서 수정${NC}\n" 23 | # printf "${BLUE}🙀 chore: - 빌딩 및 패키지 매니저 설정 및 자잘한 스크립트 수정${NC}\n" 24 | # printf "${BLUE}💬 comment: - 주석 추가${NC}\n" 25 | # printf "${BLUE}🚚 rename: - 파일 혹은 폴더명 수정 및 구조 변경${NC}\n" 26 | # printf "${BLUE}🔥 remove: - 파일 삭제하는 작업만 수행${NC}\n" 27 | # printf "${BLUE}🚨 !HOTFIX!: - 긴급 수정${NC}\n" 28 | # printf "${BLUE}🎸 etc: - 기타${NC}\n" 29 | # printf "${RED}커밋이 취소되었습니다. 위의 형식 중 하나를 사용해 주세요.${NC}\n" 30 | # exit 1 31 | # fi 32 | 33 | printf "${GREEN}✅ 올바른 커밋 메시지 형식입니다. ✅ 대 헌 지님이 정해주신 커밋 양식을 지키셨군요?${NC}\n" 34 | exit 0 35 | 36 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[0;33m' 7 | NC='\033[0m' 8 | 9 | printf "${YELLOW}🔍 pre-commit 훅을 실행합니다.${NC}\n" 10 | 11 | printf "${YELLOW}📝 lint-staged script 실행...${NC}\n" 12 | 13 | pnpm lint-staged || { 14 | printf "${RED}❌ Lint-staged script 실행을 실패하셨습니다.${NC}\n" 15 | exit 1 16 | } 17 | 18 | printf "${GREEN}✅ prettier가 자동으로 수행되었으며, lint 검사를 모두 통과하셨습니다.${NC}\n" 19 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | .github/ 5 | /workspaceRoute.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "semi": true, 7 | "arrowParens": "always", 8 | "plugins": ["prettier-plugin-tailwindcss"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "prettier.requireConfig": true, 5 | "eslint.enable": true, 6 | "eslint.useFlatConfig": true, 7 | "eslint.validate": ["javascript", "typescript", "javascriptreact", "html", "typescriptreact"], 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit" 10 | }, 11 | "eslint.workingDirectories": [ 12 | { 13 | "directory": "apps/client", 14 | "!cwd": true, 15 | "changeProcessCWD": true, 16 | "configFile": "eslint.config.js" 17 | }, 18 | { 19 | "directory": "apps/server", 20 | "!cwd": true, 21 | "changeProcessCWD": true, 22 | "configFile": "eslint.config.js" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/storybook.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Storybook MyComponent Snippet": { 3 | "prefix": "sb", 4 | "body": [ 5 | "import { Meta, StoryObj } from '@storybook/react';", 6 | "import ${1:MyComponent} from './${1:MyComponent}';", 7 | "", 8 | "const meta: Meta = {", 9 | " title: 'Category/${1:MyComponent}',", 10 | " component: ${1:MyComponent},", 11 | " parameters: {", 12 | " layout: 'centered',", 13 | " },", 14 | " tags: ['autodocs'],", 15 | "};", 16 | "", 17 | "export default meta;", 18 | "", 19 | "type Story = StoryObj;", 20 | "", 21 | "export const Default: Story = {", 22 | " args: {", 23 | " // propsname: value,", 24 | " },", 25 | "};", 26 | ], 27 | "description": "Create a Storybook story for MyComponent.", 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile.base: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | WORKDIR /app 4 | 5 | COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ 6 | 7 | RUN npm install -g pnpm 8 | RUN pnpm fetch 9 | 10 | COPY packages ./packages -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 부스트캠프 웹·모바일 9기 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 | -------------------------------------------------------------------------------- /apps/client/.storybook/decorators/HelmetProdiver.tsx: -------------------------------------------------------------------------------- 1 | import { HelmetProvider } from 'react-helmet-async'; 2 | import React from 'react'; 3 | 4 | export const withHelmetProvider = (Story) => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /apps/client/.storybook/decorators/MemoryRouterProvider.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider, createMemoryRouter } from 'react-router-dom'; 2 | 3 | import React from 'react'; 4 | 5 | export const withMemoryRouter = (Story) => { 6 | const routes = [ 7 | { 8 | path: '/', 9 | element: , 10 | }, 11 | ]; 12 | const router = createMemoryRouter(routes); 13 | return ; 14 | }; 15 | -------------------------------------------------------------------------------- /apps/client/.storybook/decorators/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | 3 | import React from 'react'; 4 | 5 | const queryClient = new QueryClient(); 6 | 7 | export const withQueryClient = (Story) => { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/client/.storybook/main.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react-vite').StorybookConfig } */ 2 | const config = { 3 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 4 | addons: [ 5 | '@storybook/addon-onboarding', 6 | '@storybook/addon-essentials', 7 | '@chromatic-com/storybook', 8 | '@storybook/addon-interactions', 9 | ], 10 | framework: { 11 | name: '@storybook/react-vite', 12 | options: {}, 13 | }, 14 | staticDirs: ['../public'], 15 | viteFinal: (config) => { 16 | config.css = { 17 | postcss: { 18 | plugins: [require('tailwindcss'), require('autoprefixer')], 19 | }, 20 | }; 21 | config.optimizeDeps = { 22 | ...config.optimizeDeps, 23 | include: ['blockly'], 24 | }; 25 | return config; 26 | }, 27 | }; 28 | export default config; 29 | -------------------------------------------------------------------------------- /apps/client/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import '../src/app/index.css'; 2 | 3 | import { withHelmetProvider } from './decorators/HelmetProdiver'; 4 | import { withMemoryRouter } from './decorators/MemoryRouterProvider'; 5 | import { withQueryClient } from './decorators/QueryProvider'; 6 | 7 | /** @type { import('@storybook/react').Preview } */ 8 | const preview = { 9 | parameters: { 10 | controls: { 11 | matchers: { 12 | color: /(background|color)$/i, 13 | date: /Date$/i, 14 | }, 15 | }, 16 | }, 17 | decorators: [withQueryClient, withMemoryRouter, withHelmetProvider], 18 | }; 19 | 20 | export default preview; 21 | -------------------------------------------------------------------------------- /apps/client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM base-image AS frontend-build 2 | WORKDIR /app/apps/client 3 | COPY ./apps/client . 4 | COPY --from=base-image /app/packages /app/packages 5 | RUN pnpm install --offline --frozen-lockfile 6 | RUN pnpm run build 7 | 8 | FROM nginx:alpine AS frontend 9 | COPY --from=frontend-build /app/apps/client/dist /usr/share/nginx/html 10 | COPY /apps/client/nginx.conf /etc/nginx/conf.d/default.conf 11 | COPY /apps/client/ssl /etc/nginx/ssl 12 | 13 | RUN chmod -R 755 /usr/share/nginx/html 14 | RUN chmod 644 /etc/nginx/ssl/fullchain.pem 15 | RUN chmod 600 /etc/nginx/ssl/privkey.pem 16 | RUN chown -R nginx:nginx /usr/share/nginx/html /etc/nginx/ssl 17 | 18 | EXPOSE 80 443 19 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /apps/client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import defaultConfig from '@packages/eslint'; 2 | import react from 'eslint-plugin-react'; 3 | import reactHooksPlugin from 'eslint-plugin-react-hooks'; 4 | import reactRefreshPlugin from 'eslint-plugin-react-refresh'; 5 | import storybookPlugin from 'eslint-plugin-storybook'; 6 | 7 | import { fileURLToPath } from 'url'; 8 | import { dirname } from 'path'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | 13 | export default [ 14 | defaultConfig.jsCommended, 15 | defaultConfig.base, 16 | { 17 | files: [...defaultConfig.base.files, '**/*.{jsx,tsx}'], 18 | plugins: { 19 | ...defaultConfig.base.plugins, 20 | react, 21 | 'react-hooks': reactHooksPlugin, 22 | 'react-refresh': reactRefreshPlugin, 23 | storybook: storybookPlugin, 24 | }, 25 | languageOptions: { 26 | ...defaultConfig.base.languageOptions, 27 | globals: { 28 | ...defaultConfig.base.languageOptions.globals, 29 | React: true, 30 | }, 31 | parserOptions: { 32 | ecmaFeatures: { 33 | jsx: true, 34 | }, 35 | project: './tsconfig.json', 36 | tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), 37 | }, 38 | }, 39 | settings: { 40 | react: { 41 | version: '18.3.1', 42 | }, 43 | 'import/resolver': { 44 | alias: { 45 | map: [['@', './src']], 46 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 47 | }, 48 | }, 49 | }, 50 | }, 51 | { 52 | ignores: [ 53 | ...defaultConfig.base.ignores, 54 | '**/dist/**/*', 55 | '**/vite-env.d.ts', 56 | '**/.storybook/**', 57 | ], 58 | }, 59 | defaultConfig.ignorePrettier, 60 | ]; 61 | -------------------------------------------------------------------------------- /apps/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | BooLock 9 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /apps/client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name boolock.site; 4 | 5 | location / { 6 | return 301 https://$host$request_uri; 7 | } 8 | } 9 | 10 | server { 11 | listen 443 ssl; 12 | server_name boolock.site; 13 | 14 | ssl_certificate /etc/nginx/ssl/fullchain.pem; 15 | ssl_certificate_key /etc/nginx/ssl/privkey.pem; 16 | 17 | location / { 18 | root /usr/share/nginx/html; 19 | try_files $uri $uri/ /index.html; 20 | } 21 | 22 | location /api/ { 23 | proxy_pass http://backend:3000; 24 | proxy_http_version 1.1; 25 | proxy_set_header Upgrade $http_upgrade; 26 | proxy_set_header Connection 'upgrade'; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Real-IP $remote_addr; 29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 30 | proxy_cache_bypass $http_upgrade; 31 | } 32 | 33 | add_header X-Content-Type-Options nosniff; 34 | add_header X-Frame-Options SAMEORIGIN; 35 | add_header X-XSS-Protection "1; mode=block"; 36 | 37 | gzip on; 38 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 39 | gzip_min_length 256; 40 | } 41 | -------------------------------------------------------------------------------- /apps/client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/client/public/fonts/SUIT-Variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web31-BooLock/18a3d1c18cb01ffe4bb1cb4cf0c6cc54249875ce/apps/client/public/fonts/SUIT-Variable.woff2 -------------------------------------------------------------------------------- /apps/client/public/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/public/images/boolock_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web31-BooLock/18a3d1c18cb01ffe4bb1cb4cf0c6cc54249875ce/apps/client/public/images/boolock_logo.png -------------------------------------------------------------------------------- /apps/client/public/images/boolock_thumnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web31-BooLock/18a3d1c18cb01ffe4bb1cb4cf0c6cc54249875ce/apps/client/public/images/boolock_thumnail.png -------------------------------------------------------------------------------- /apps/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # 모든 크롤러에게 전체 사이트 크롤링 허용 2 | User-agent: * 3 | Disallow: 4 | 5 | # 사이트맵 위치 지정 6 | Sitemap: http://www.boolock.site/sitemap.xml -------------------------------------------------------------------------------- /apps/client/src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | 5 | import { App } from './App'; 6 | import { StrictMode } from 'react'; 7 | import { createRoot } from 'react-dom/client'; 8 | import { HelmetProvider } from 'react-helmet-async'; 9 | 10 | const container = document.getElementById('root'); 11 | const root = createRoot(container!); 12 | 13 | const queryClient = new QueryClient(); 14 | 15 | root.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /apps/client/src/core/customFieldLabelSerializable.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | /* 4 | *해당 field 라벨의 text에 클래스가 이름이, 일반 블록의 blocklyText 클래스 이름과 같아 일반 블록에 적용된 gray-50 색상과 동일하게 text 색상이 적용되었습니다. 5 | *이를 해결하기 위한 용도의 커스텀클래스입니다. initView()에서 텍스트 색상만 변경해주는 용도입니다. 6 | */ 7 | export class CustomFieldLabelSerializable extends Blockly.FieldLabelSerializable { 8 | constructor(value?: string, textClass?: string, config?: Blockly.FieldLabelConfig) { 9 | super(String(value ?? ''), textClass, config); 10 | } 11 | 12 | override initView(): void { 13 | super.initView(); 14 | if (this.textElement_) { 15 | this.textElement_.style.fill = `#1E272E`; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/client/src/core/customFieldTextInput.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | const dom = Blockly.utils.dom; 4 | 5 | /* 6 | *text블록의 inputField에서 사용되고 있습니다. 7 | *text블록의 inputField처럼 내부에서 수정이 불가능한 상태 및 고정된 상태로 사용된다고 했을때, 해당 필드에 대해 minwidth가 잘 먹히지 않았습니다. 8 | *updateSize_()의 내부 로직을 일부 수정하여 minWidth를 설정해주었습니다. 9 | *super.updateSize_() 이후 설정해주기 어려워 해당 메소드를 그대로 복붙해와서 사용중입니다. 10 | *만약 text블록 이외의 다른 곳에서 사용된다고 했을 때, widgetCreate_() 메소드에 인해 포커싱이 되어도 블록 모양이 망가지지 않고 잘 작동됩니다. 11 | */ 12 | export class CustomFieldTextInput extends Blockly.FieldTextInput { 13 | width = 0; 14 | 15 | updateWidth(width: number) { 16 | this.width = width; 17 | this.render_(); 18 | } 19 | 20 | protected override updateSize_(margin?: number): void { 21 | const constants = this.getConstants(); 22 | const xOffset = 23 | margin !== undefined 24 | ? margin 25 | : !this.isFullBlockField() 26 | ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING 27 | : 0; 28 | let totalWidth = xOffset * 2; 29 | let totalHeight = constants!.FIELD_TEXT_HEIGHT; 30 | 31 | this.width = Math.max(constants!.EMPTY_INLINE_INPUT_PADDING + 8, this.width); 32 | 33 | let contentWidth = 0; 34 | if (this.textElement_) { 35 | contentWidth = dom.getFastTextWidth( 36 | this.textElement_, 37 | constants!.FIELD_TEXT_FONTSIZE, 38 | constants!.FIELD_TEXT_FONTWEIGHT, 39 | constants!.FIELD_TEXT_FONTFAMILY 40 | ); 41 | totalWidth += contentWidth; 42 | } 43 | if (!this.isFullBlockField()) { 44 | totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT); 45 | } 46 | 47 | this.size_.height = totalHeight; 48 | this.size_.width = Math.max(totalWidth, this.width); 49 | 50 | this.positionTextElement_(xOffset, contentWidth); 51 | this.positionBorderRect_(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /apps/client/src/core/customOptionFieldLabel.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | const dom = Blockly.utils.dom; 4 | const Svg = Blockly.utils.Svg; 5 | 6 | // 블록의 좌측 블록 이름에 대한 라벨을 실제 돔에 올려주는 클래스입니다. 7 | export class CustomOptionFieldLabel extends Blockly.FieldLabel { 8 | protected override createTextElement_(): void { 9 | this.textElement_ = dom.createSvgElement( 10 | Svg.TEXT, 11 | { 12 | class: 'blocklyText', 13 | x: 0, 14 | y: 0, 15 | 'dominant-baseline': 'central', 16 | }, 17 | this.fieldGroup_ 18 | ); 19 | 20 | this.textContent_ = document.createTextNode(''); 21 | this.textElement_.appendChild(this.textContent_); 22 | } 23 | 24 | /* 25 | *실제 dom에 올려주는 역할을 하는 메소드입니다. 26 | *블록 태그 내에 합쳐서 올릴 때는 블록을 배경으로 약간 좌측에 padding넣어준 것처럼 27 | *해당 필드가 위치해있어야하기 때문에 이에 대한 x좌표 및 배경에 대한 style 적용을 해두었습니다. 28 | */ 29 | protected override render_(): void { 30 | super.render_(); 31 | 32 | const bbox = this.textElement_!.getBBox(); 33 | if (this.textElement_) { 34 | this.textElement_.setAttribute('x', (bbox.x + 6).toString()); 35 | this.textElement_.setAttribute('y', (bbox.y + 9).toString()); 36 | this.textElement_.style.fill = `#1E272E`; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/client/src/core/customRenderer.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | import { CustomConstantProvider } from './customConstantProvider'; 3 | import { CustomRenderInfo } from './customRenderInfo'; 4 | 5 | // 커스텀한 constant 및 renderInfo를 custom renderer에 등록시켜 블록 생성할 시 사용합니다. 6 | export class CustomRenderer extends Blockly.zelos.Renderer { 7 | constructor(name: string) { 8 | super(name); 9 | } 10 | 11 | protected override makeConstants_(): CustomConstantProvider { 12 | return new CustomConstantProvider(); 13 | } 14 | 15 | protected override makeRenderInfo_(block: Blockly.BlockSvg): Blockly.zelos.RenderInfo { 16 | return new CustomRenderInfo(this, block); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/client/src/core/customTooltip.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | export const customTooltip = (p1: Element, p2: Element): void => { 4 | const content = document.createElement('p'); 5 | content.style.whiteSpace = 'pre-wrap'; 6 | content.style.fontFamily = 'SUIT Variable'; 7 | content.textContent = (p2 as unknown as Blockly.BlockSvg).getTooltip(); 8 | p1.appendChild(content); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/client/src/core/dom.ts: -------------------------------------------------------------------------------- 1 | import { Svg } from 'blockly/core/utils'; 2 | 3 | export default class Dom { 4 | static SVG_NS = 'http://www.w3.org/2000/svg'; 5 | 6 | static createElement( 7 | name: string, 8 | attrs: { [key: string]: string | number }, 9 | parent?: Element | null 10 | ): T { 11 | const element = document.createElement(name) as T; 12 | for (const key in attrs) { 13 | element.setAttribute(key, `${attrs[key]}`); 14 | } 15 | if (parent) { 16 | parent.appendChild(element); 17 | } 18 | return element; 19 | } 20 | 21 | static createSvgElement( 22 | name: string | Svg, 23 | attrs: { [key: string]: string | number }, 24 | optParent?: Element | null 25 | ): T { 26 | const e = document.createElementNS(this.SVG_NS, `${name}`) as unknown as T; 27 | for (const key in attrs) { 28 | e.setAttribute(key, `${attrs[key]}`); 29 | } 30 | if (optParent) { 31 | optParent.appendChild(e); 32 | } 33 | return e; 34 | } 35 | 36 | static insertAfter(newNode: Element, refNode: Element) { 37 | const siblingNode = refNode.nextElementSibling; 38 | const parentNode = refNode.parentNode; 39 | if (!parentNode) { 40 | throw Error('Reference node has no parent.'); 41 | } 42 | if (siblingNode) { 43 | parentNode.insertBefore(newNode, siblingNode); 44 | } else { 45 | parentNode.appendChild(newNode); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/client/src/core/register.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | import CustomCategory from './customCategory'; 3 | import FixedFlyout from './fixedFlyout'; 4 | import StyleFlyout from './styleFlyout'; 5 | import { CustomRenderer } from './customRenderer'; 6 | 7 | export const registerCustomComponents = () => { 8 | Blockly.blockRendering.register('boolock', CustomRenderer); 9 | 10 | Blockly.registry.register( 11 | Blockly.registry.Type.TOOLBOX_ITEM, 12 | Blockly.ToolboxCategory.registrationName, 13 | CustomCategory, 14 | true 15 | ); 16 | 17 | Blockly.registry.register( 18 | Blockly.registry.Type.FLYOUTS_VERTICAL_TOOLBOX, 19 | FixedFlyout.registryName, 20 | FixedFlyout, 21 | true 22 | ); 23 | 24 | Blockly.registry.register( 25 | Blockly.registry.Type.FLYOUTS_VERTICAL_TOOLBOX, 26 | StyleFlyout.registryName, 27 | StyleFlyout, 28 | true 29 | ); 30 | 31 | Blockly.Css.register(` 32 | .blocklyZoom>image, .blocklyZoom>svg>image { 33 | opacity: .6; 34 | } 35 | 36 | .blocklyZoom>image:hover, .blocklyZoom>svg>image:hover { 37 | opacity: .8; 38 | } 39 | 40 | .blocklyZoom>image:active, .blocklyZoom>svg>image:active { 41 | opacity: 1; 42 | } 43 | `); 44 | }; 45 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/HoveredEmptyWorkspace/HoveredEmptyWorkspace.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { HoveredEmptyWorkspace } from './HoveredEmptyWorkspace'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/home/HoveredEmptyWorkspace', 8 | component: HoveredEmptyWorkspace, 9 | parameters: { 10 | layout: 'fullscreen', 11 | }, 12 | decorators: [ 13 | (Story) => ( 14 |
15 | 16 |
17 | ), 18 | ], 19 | tags: ['autodocs'], 20 | }; 21 | 22 | export default meta; 23 | 24 | type Story = StoryObj; 25 | 26 | export const Default: Story = {}; 27 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/HoveredEmptyWorkspace/HoveredEmptyWorkspace.tsx: -------------------------------------------------------------------------------- 1 | import PlusGreen from '@/shared/assets/plus_green.svg?react'; 2 | import { useCreateWorkspace } from '@/shared/hooks'; 3 | 4 | /** 5 | * 6 | * @description 7 | * 빈 워크스페이스에 마우스를 올렸을 때 보여지는 컴포넌트 8 | */ 9 | export const HoveredEmptyWorkspace = () => { 10 | const { mutate: createWorkspace } = useCreateWorkspace(); 11 | 12 | const handleClick = () => { 13 | createWorkspace(); 14 | }; 15 | 16 | return ( 17 |
21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/NotHoveredEmptyWorkspace/NotHoveredEmptyWorkspace.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { NotHoveredEmptyWorkspace } from './NotHoveredEmptyWorkspace'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/home/NotHoveredEmptyWorkspace', 7 | component: NotHoveredEmptyWorkspace, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/NotHoveredEmptyWorkspace/NotHoveredEmptyWorkspace.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @description 3 | * 워크스페이스가 없을 때, 마우스가 올라가지 않은 상태의 빈 워크스페이스 컴포넌트 4 | */ 5 | export const NotHoveredEmptyWorkspace = () => { 6 | return ( 7 |
8 | 13 |

14 | 아직 워크스페이스가 없어요!
새로운 웹 페이지를 만들어보세요! 15 |

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceAddBtn/WorkspaceAddBtn.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceAddBtn } from './WorkspaceAddBtn'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/home/WorkspaceAddBtn', 8 | component: WorkspaceAddBtn, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | (Story) => ( 14 |
15 | 16 |
17 | ), 18 | ], 19 | tags: ['autodocs'], 20 | }; 21 | 22 | export default meta; 23 | 24 | type Story = StoryObj; 25 | 26 | export const Default: Story = {}; 27 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceAddBtn/WorkspaceAddBtn.tsx: -------------------------------------------------------------------------------- 1 | import { CircleButton } from '@/shared/ui'; 2 | import PlusSVG from '@/shared/assets/plus.svg?react'; 3 | import { useCreateWorkspace } from '@/shared/hooks'; 4 | import { useLoadingStore } from '@/shared/store'; 5 | 6 | /** 7 | * 8 | * @description 9 | * 워크스페이스 추가 버튼 컴포넌트 10 | */ 11 | export const WorkspaceAddBtn = () => { 12 | const { mutate: createWorkspace } = useCreateWorkspace(); 13 | const { isPending } = useLoadingStore(); 14 | 15 | const handleClick = () => { 16 | createWorkspace(); 17 | }; 18 | 19 | return ( 20 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceItem/WorkspaceItem.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceItem } from './WorkspaceItem'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/home/WorkspaceItem', 8 | component: WorkspaceItem, 9 | parameters: { 10 | layout: 'fullscreen', 11 | }, 12 | 13 | tags: ['autodocs'], 14 | }; 15 | 16 | export default meta; 17 | 18 | type Story = StoryObj; 19 | 20 | export const Default: Story = { 21 | args: { 22 | workspaceId: '4ddcbf25-acb0-42cd-8a97-aeda515e26db', 23 | title: '스토리북용 워크스페이스', 24 | thumbnail: '', 25 | lastEdited: '2024-11-28T09:34:45.106+00:00', 26 | onClick: () => { 27 | action('workspaceItem clicked')(); 28 | }, 29 | }, 30 | render: (args) => ( 31 |
32 |
    33 | 34 |
35 |
36 | ), 37 | }; 38 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceLoadError/WorkspaceLoadError.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceLoadError } from './WorkspaceLoadError'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/home/WorkspaceLoadError', 7 | component: WorkspaceLoadError, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceLoadError/WorkspaceLoadError.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @description 4 | * 워크스페이스 불러오기 실패 시 렌더링 되는 에러 컴포넌트 5 | */ 6 | export const WorkspaceLoadError = () => { 7 | return ( 8 |
9 | 10 |

워크스페이스를 불러오지 못했습니다.

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceSampleButton/WorkspaceSampleButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceSampleButton } from './WorkspaceSampleButton'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/home/WorkspaceSampleButton', 7 | component: WorkspaceSampleButton, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/entities/home/WorkspaceSampleButton/WorkspaceSampleButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCreateWorkspace } from '@/shared/hooks'; 2 | import { useLoadingStore } from '@/shared/store'; 3 | import { Spinner } from '@/shared/ui'; 4 | 5 | /** 6 | * 7 | * @description 8 | * Workspace 샘플을 생성하는 버튼입니다. 9 | */ 10 | export const WorkspaceSampleButton = () => { 11 | const { mutate: createWorkspace } = useCreateWorkspace(true); 12 | const { isPending } = useLoadingStore(); 13 | 14 | const handleClick = () => { 15 | createWorkspace(); 16 | }; 17 | 18 | return ( 19 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/client/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export { WorkspaceItem } from './home/WorkspaceItem/WorkspaceItem'; 2 | export { WorkspaceAddBtn } from './home/WorkspaceAddBtn/WorkspaceAddBtn'; 3 | export { WorkspaceLoadError } from './home/WorkspaceLoadError/WorkspaceLoadError'; 4 | export { HoveredEmptyWorkspace } from './home/HoveredEmptyWorkspace/HoveredEmptyWorkspace'; 5 | export { NotHoveredEmptyWorkspace } from './home/NotHoveredEmptyWorkspace/NotHoveredEmptyWorkspace'; 6 | export { WorkspaceSampleButton } from './home/WorkspaceSampleButton/WorkspaceSampleButton'; 7 | 8 | export { RedoButton } from './workspace/RedoButton/RedoButton'; 9 | export { UndoButton } from './workspace/UndoButton/UndoButton'; 10 | export { SaveButton } from './workspace/SaveButton/SaveButton'; 11 | export { WorkspaceNameInput } from './workspace/WorkspaceNameInput/WorkspaceNameInput'; 12 | export { CssTooltip } from './workspace/CssTooltip/CssTooltip'; 13 | export { CssOptionItem } from './workspace/CssOptionItem/CssOptionItem'; 14 | export { CssCategoryButton } from './workspace/CssCategoryButton/CssCategoryButton'; 15 | export { RenderResetCssTooltip } from './workspace/RenderResetCssTooltip/RenderResetCssTooltip'; 16 | export { ImageTagModalList } from './workspace/ImageTagModalList/ImageTagModalList'; 17 | export { ImageTagModalHeader } from './workspace/ImageTagModalHeader/ImageTagModalHeader'; 18 | export { ImageTagModalImg } from './workspace/ImageTagModalImg/ImageTagModalImg'; 19 | export { ImageTagModalButton } from './workspace/ImageTagModalButton/ImageTagModalButton'; 20 | export { ImageTagModalListItem } from './workspace/ImageTagModalListItem/ImageTagModalListItem'; 21 | export { CodeExportButton } from './workspace/CodeExportButton/CodeExportButton'; 22 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CodeExportButton/CodeExportButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CodeExportButton } from './CodeExportButton'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/CodeExportButton', 7 | component: CodeExportButton, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CodeExportButton/CodeExportButton.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@/shared/ui'; 2 | import { exportPreviewHtml } from '@/shared/utils'; 3 | import toast from 'react-hot-toast'; 4 | import { useState } from 'react'; 5 | 6 | export const CodeExportButton = () => { 7 | const [isLoading, setIsLoading] = useState(false); 8 | 9 | const handleClick = () => { 10 | try { 11 | setIsLoading(true); 12 | exportPreviewHtml(); 13 | } catch (error) { 14 | if (error instanceof Error) { 15 | toast.error(error.message); 16 | } 17 | } finally { 18 | setIsLoading(false); 19 | } 20 | }; 21 | 22 | return ( 23 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CssCategoryButton/CssCategoryButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssCategoryButton } from './CssCategoryButton'; 4 | import { useCssPropsStore } from '@/shared/store'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/workspace/CssCateGoryButton', 8 | component: CssCategoryButton, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = { 20 | args: { 21 | cssCategory: '레이아웃', 22 | }, 23 | render: (args) => { 24 | const { selectedCssCategory } = useCssPropsStore(); 25 | return ( 26 |
27 |

현재 선택된 카테고리 : {selectedCssCategory}

28 | 29 |
30 | ); 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CssCategoryButton/CssCategoryButton.tsx: -------------------------------------------------------------------------------- 1 | import { TCssCategory } from '@/shared/types'; 2 | import { useCssPropsStore } from '@/shared/store'; 3 | 4 | type CssCategoryButtonProps = { 5 | cssCategory: TCssCategory; 6 | }; 7 | 8 | /** 9 | * 10 | * @description 11 | * CSS 카테고리를 선택할 수 있는 버튼 컴포넌트 12 | */ 13 | export const CssCategoryButton = ({ cssCategory }: CssCategoryButtonProps) => { 14 | const { selectedCssCategory, setSelectedCssCategory } = useCssPropsStore(); 15 | return ( 16 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CssOptionItem/CssOptionItem.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssOptionItem } from './CssOptionItem'; 4 | import { cssCategoryList } from '@/shared/utils'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/workspace/CssOptionItem', 8 | component: CssOptionItem, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = { 20 | args: { 21 | cssItem: cssCategoryList[0].items[0], 22 | index: 0, 23 | }, 24 | }; 25 | 26 | export const Resize: Story = { 27 | render: () => { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | }, 34 | }; 35 | 36 | export const ResizeMultipleItems: Story = { 37 | render: () => { 38 | const selectedCssCategory = '레이아웃'; 39 | return ( 40 |
41 | {cssCategoryList 42 | .filter((cssCategory) => cssCategory.category === selectedCssCategory) 43 | .map((cssCategory) => 44 | cssCategory.items.map((cssItem, index) => ( 45 | 46 | )) 47 | )} 48 |
49 | ); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/CssTooltip/CssTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { createPortal } from 'react-dom'; 2 | 3 | type CssTooltipProps = { 4 | description: string; 5 | isOpen: boolean; 6 | leftX: number; 7 | topY: number; 8 | }; 9 | 10 | export const CssTooltip = ({ description, isOpen, leftX, topY }: CssTooltipProps) => { 11 | if (!isOpen) { 12 | return null; 13 | } 14 | return createPortal( 15 |
= 0 ? 'rounded-tl-none' : 'rounded-bl-none'} bg-green-500 px-3 py-2`} 17 | style={{ left: `${leftX + 18}px`, top: topY >= 0 ? `${topY + 8}px` : `${-topY}px` }} 18 | > 19 |

{description}

20 |
, 21 | document.body 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalButton/ImageTagModalButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ImageTagModalButton } from './ImageTagModalButton'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/ImageTagModalButton', 7 | component: ImageTagModalButton, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: 'img 태그 src 속성 적용을 위한 모달창에 사용되는 버튼 컴포넌트', 13 | }, 14 | }, 15 | }, 16 | tags: ['autodocs'], 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = { 24 | args: { 25 | content: '이미지 선택하기', 26 | isBlue: true, 27 | onClick: () => {}, 28 | }, 29 | render: (args) => { 30 | return ( 31 | 32 | ); 33 | }, 34 | }; 35 | 36 | export const Close: Story = { 37 | args: { 38 | content: '닫기', 39 | isBlue: false, 40 | onClick: () => {}, 41 | }, 42 | render: (args) => { 43 | return ( 44 | 45 | ); 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalButton/ImageTagModalButton.tsx: -------------------------------------------------------------------------------- 1 | type ImageTagModalButtonProps = { 2 | content: string; 3 | isBlue: boolean; 4 | onClick: () => void; 5 | }; 6 | 7 | export const ImageTagModalButton = ({ content, isBlue, onClick }: ImageTagModalButtonProps) => { 8 | return ( 9 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalHeader/ImageTagModalHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ImageTagModalHeader } from './ImageTagModalHeader'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/ImageTagModalHeader', 7 | component: ImageTagModalHeader, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: 'img 태그 src 속성 적용을 위한 모달창에 사용되는 헤더', 13 | }, 14 | }, 15 | }, 16 | tags: ['autodocs'], 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = { 24 | args: { 25 | onClose: () => {}, 26 | }, 27 | render: (args) => { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalHeader/ImageTagModalHeader.tsx: -------------------------------------------------------------------------------- 1 | import XIcon from '@/shared/assets/x_icon.svg?react'; 2 | import { useImageModalStore } from '@/shared/store'; 3 | 4 | /** 5 | * @component 6 | * @description 7 | * 이미지 선택 모달의 헤더를 구성하는 컴포넌트입니다. 8 | * 제목과 닫기 버튼을 포함하며, 닫기 버튼 클릭 시 모달을 닫습니다. 9 | */ 10 | export const ImageTagModalHeader = ({ onClose }: { onClose: () => void }) => { 11 | const { setIsModalOpen } = useImageModalStore(); 12 | 13 | const handleClose = () => { 14 | onClose(); 15 | setIsModalOpen(false); 16 | }; 17 | 18 | return ( 19 | 20 | 이미지 선택 21 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalImg/ImageTagModalImg.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ImageTagModalImg } from './ImageTagModalImg'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/ImageTagModalImg', 7 | component: ImageTagModalImg, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: 'img 태그 src 속성 적용을 위한 모달창에 사용되는 이미지 미리보기 컴포넌트', 13 | }, 14 | }, 15 | }, 16 | tags: ['autodocs'], 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = { 24 | args: { 25 | imageSrc: `${import.meta.env.VITE_STATIC_STORAGE_URL}boolock_logo.png`, 26 | }, 27 | render: (args) => { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalImg/ImageTagModalImg.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | /** 4 | * @component 5 | * @description 6 | * 임시로 업로드된 이미지 또는 실제 src로부터 제공된 이미지의 미리보기를 표시하는 컴포넌트입니다. 7 | * 유효하지 않은 이미지일 경우 에러 메시지를 표시합니다. 8 | */ 9 | export const ImageTagModalImg = ({ imageSrc }: { imageSrc: string }) => { 10 | const [isError, setIsError] = useState(false); 11 | 12 | useEffect(() => { 13 | setIsError(false); 14 | }, [imageSrc]); 15 | 16 | return ( 17 |
18 | {isError ? ( 19 |
20 | 오른쪽 목록에서 21 |
22 | 이미지를 선택해주세요 23 |
24 | ) : ( 25 | Preview setIsError(true)} 30 | /> 31 | )} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalList/ImageTagModalList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ImageTagModalList } from './ImageTagModalList'; 4 | import { useState } from 'react'; 5 | import { useImageModalStore } from '@/shared/store'; 6 | 7 | const meta: Meta = { 8 | title: 'entities/workspace/ImageTagModalList', 9 | component: ImageTagModalList, 10 | parameters: { 11 | layout: 'centered', 12 | docs: { 13 | description: { 14 | component: 'img 태그 src 속성 적용을 위한 모달창에 사용되는 이미지 리스트 컴포넌트', 15 | }, 16 | }, 17 | }, 18 | tags: ['autodocs'], 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | args: { 27 | tagSrc: '/mock/image2.png', 28 | onSetTagSrc: () => {}, 29 | }, 30 | render: (args) => { 31 | const [tagSrc, setTagSrc] = useState(args.tagSrc); 32 | const mockImageList = new Map([ 33 | ['example1 47 | 48 | 49 | ); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/ImageTagModalListItem/ImageTagModalListItem.tsx: -------------------------------------------------------------------------------- 1 | import TrashSVG from '@/shared/assets/trash.svg?react'; 2 | 3 | type ImageListItemProps = { 4 | isSelected: boolean; 5 | onSelectImage: () => void; 6 | onDeleteImage: () => void; 7 | filename: string; 8 | }; 9 | 10 | export const ImageTagModalListItem = ({ 11 | isSelected, 12 | onDeleteImage, 13 | onSelectImage, 14 | filename, 15 | }: ImageListItemProps) => { 16 | return ( 17 |
23 | {filename.replace(/ 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/RedoButton/RedoButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { RedoButton } from './RedoButton'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/workspace/RedoButton', 8 | component: RedoButton, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | (Story) => { 14 | const handleClick = (event: React.MouseEvent) => { 15 | event.preventDefault(); 16 | action('redo button clicked')(); 17 | }; 18 | return ( 19 |
20 | 21 |
22 | ); 23 | }, 24 | ], 25 | tags: ['autodocs'], 26 | }; 27 | 28 | export default meta; 29 | 30 | type Story = StoryObj; 31 | 32 | export const Default: Story = {}; 33 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/RedoButton/RedoButton.tsx: -------------------------------------------------------------------------------- 1 | import { CircleButton } from '@/shared/ui'; 2 | import RightArrow from '@/shared/assets/arrow_right.svg?react'; 3 | import { useWorkspaceStore } from '@/shared/store'; 4 | 5 | /** 6 | * @description 7 | * 워크스페이스 캔버스에서 redo 기능을 실행시키는 버튼입니다. 8 | */ 9 | export const RedoButton = () => { 10 | const { workspace } = useWorkspaceStore(); 11 | 12 | const handleRedo = () => { 13 | if (workspace !== null) { 14 | workspace.undo(true); 15 | } 16 | }; 17 | 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/RenderResetCssTooltip/RenderResetCssTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { CssTooltip } from '@/entities'; 3 | 4 | type TooltipProps = { 5 | description: string; 6 | isOpen: boolean; 7 | leftX: number; 8 | topY: number; 9 | }; 10 | 11 | let root: ReturnType | null = null; 12 | 13 | export const RenderResetCssTooltip = (props: TooltipProps, container: HTMLElement) => { 14 | if (!root) { 15 | root = createRoot(container); 16 | } 17 | 18 | root.render(CssTooltip(props)); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/SaveButton/SaveButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { SaveButton } from './SaveButton'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/workspace/SaveButton', 8 | component: SaveButton, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | (Story) => { 14 | const handleClick = (event: React.MouseEvent) => { 15 | event.preventDefault(); 16 | action('save button clicked')(); 17 | }; 18 | return ( 19 |
20 | 21 |
22 | ); 23 | }, 24 | ], 25 | tags: ['autodocs'], 26 | }; 27 | 28 | export default meta; 29 | 30 | type Story = StoryObj; 31 | 32 | export const Default: Story = {}; 33 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/UndoButton/UndoButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { UndoButton } from './UndoButton'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'entities/workspace/UndoButton', 8 | component: UndoButton, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | (Story) => { 14 | const handleClick = (event: React.MouseEvent) => { 15 | event.preventDefault(); 16 | action('undo button clicked')(); 17 | }; 18 | return ( 19 |
20 | 21 |
22 | ); 23 | }, 24 | ], 25 | tags: ['autodocs'], 26 | }; 27 | 28 | export default meta; 29 | 30 | type Story = StoryObj; 31 | 32 | export const Default: Story = {}; 33 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/UndoButton/UndoButton.tsx: -------------------------------------------------------------------------------- 1 | import { CircleButton } from '@/shared/ui'; 2 | import LeftArrow from '@/shared/assets/arrow_left.svg?react'; 3 | import { useWorkspaceStore } from '@/shared/store'; 4 | 5 | /** 6 | * 7 | * @description 8 | * 워크스페이스 캔버스에서 undo 기능을 실행시키는 버튼입니다. 9 | */ 10 | export const UndoButton = () => { 11 | const { workspace } = useWorkspaceStore(); 12 | 13 | const handleUndo = () => { 14 | if (workspace !== null) { 15 | workspace.undo(false); 16 | } 17 | }; 18 | 19 | return ( 20 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/client/src/entities/workspace/WorkspaceNameInput/WorkspaceNameInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceNameInput } from './WorkspaceNameInput'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/WorkspaceNameInput', 7 | component: WorkspaceNameInput, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/pages/ErrorPage/ErrorPage.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ErrorPage } from './ErrorPage'; 4 | 5 | const meta: Meta = { 6 | title: 'pages/ErrorPage', 7 | component: ErrorPage, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | decorators: [ 12 | (Story) => { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | }, 19 | ], 20 | tags: ['autodocs'], 21 | }; 22 | 23 | export default meta; 24 | 25 | type Story = StoryObj; 26 | 27 | export const Default: Story = {}; 28 | -------------------------------------------------------------------------------- /apps/client/src/pages/ErrorPage/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet-async'; 2 | 3 | import { ErrorContent } from '@/shared/ui/error/ErrorContent'; 4 | 5 | // TODO: 메세지 상수화 shared/utils/constants.ts 안에 관리 6 | /** 7 | * 8 | * @description 9 | * 워크스페이스 에러 페이지 컴포넌트 10 | */ 11 | export const ErrorPage = () => { 12 | return ( 13 | <> 14 | 15 | BooLock - 에러 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/client/src/pages/HomePage/HomePage.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { HomePage } from './HomePage'; 4 | 5 | const meta: Meta = { 6 | title: 'pages/HomePage', 7 | component: HomePage, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = { 20 | args: { 21 | // propsname: value, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/pages/HomePage/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Banner, HomeHeader, WorkspaceContainer, WorkspaceModal } from '@/widgets'; 2 | import { useClassBlockStore, useLoadingStore, useWorkspaceStore } from '@/shared/store'; 3 | 4 | import { Loading } from '@/shared/ui'; 5 | import { useEffect } from 'react'; 6 | 7 | /** 8 | * 9 | * @description 10 | * Boolock 홈페이지 컴포넌트 11 | */ 12 | export const HomePage = () => { 13 | const { isPending } = useLoadingStore(); 14 | const { setWorkspace, setCanvasInfo: setBlockInfo } = useWorkspaceStore(); 15 | const { initClassBlockList } = useClassBlockStore(); 16 | 17 | useEffect(() => { 18 | setWorkspace(null); 19 | setBlockInfo(''); 20 | initClassBlockList([]); 21 | }, []); 22 | 23 | return ( 24 | <> 25 | {isPending && } 26 |
27 | 28 | 29 | 30 | 31 |
32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/pages/NotFound/NotFound.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { NotFound } from './NotFound'; 4 | 5 | const meta: Meta = { 6 | title: 'pages/NotFound', 7 | component: NotFound, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | decorators: [ 12 | (Story) => ( 13 |
14 | 15 |
16 | ), 17 | ], 18 | tags: ['autodocs'], 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | args: { 27 | // propsname: value, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/pages/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorContent } from '@/shared/ui/error/ErrorContent'; 2 | 3 | // TODO: 메세지 상수화 shared/utils/constants.ts 안에 관리 4 | /** 5 | * 6 | * @description 7 | * 404 페이지 컴포넌트 8 | */ 9 | export const NotFound = () => { 10 | return ( 11 | <> 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/client/src/pages/Workspacepage/WorkspacePage.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspacePage } from './WorkspacePage'; 4 | 5 | const meta: Meta = { 6 | title: 'pages/WorkspacePage', 7 | component: WorkspacePage, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | decorators: [ 12 | (Story) => ( 13 |
14 | 15 |
16 | ), 17 | ], 18 | tags: ['autodocs'], 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | args: { 27 | // propsname: value, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/pages/Workspacepage/WorkspacePage.tsx: -------------------------------------------------------------------------------- 1 | import { ImageTagModal, CoachMark, WorkspaceContent, WorkspacePageHeader } from '@/widgets'; 2 | import { useGetWorkspace, usePreventLeaveWorkspacePage } from '@/shared/hooks'; 3 | import { Loading } from '@/shared/ui'; 4 | import { NotFound } from '@/pages/NotFound/NotFound'; 5 | import { useParams } from 'react-router-dom'; 6 | import { useLayoutEffect, useEffect } from 'react'; 7 | import { useCoachMarkStore } from '@/shared/store/useCoachMarkStore'; 8 | 9 | /** 10 | * 11 | * @description 12 | * 워크스페이스 페이지 컴포넌트 13 | */ 14 | export const WorkspacePage = () => { 15 | const { workspaceId } = useParams(); 16 | const { isPending, isError } = useGetWorkspace(workspaceId as string); 17 | usePreventLeaveWorkspacePage(); 18 | const { currentStep, isCoachMarkOpen, openCoachMark } = useCoachMarkStore(); 19 | const toolboxDiv = document.querySelector('.blocklyToolboxDiv'); 20 | 21 | useLayoutEffect(() => { 22 | const isCoachMarkDismissed = localStorage.getItem('isCoachMarkDismissed'); 23 | 24 | if (!isCoachMarkDismissed) { 25 | openCoachMark(); 26 | } 27 | }, []); 28 | 29 | useEffect(() => { 30 | if (!toolboxDiv) return; 31 | 32 | if (currentStep <= 1) { 33 | toolboxDiv.classList.add('coachMarkHighlight'); 34 | } else { 35 | toolboxDiv.classList.remove('coachMarkHighlight'); 36 | } 37 | }, [currentStep, toolboxDiv]); 38 | 39 | if (isError) { 40 | return ; 41 | } 42 | 43 | return ( 44 | <> 45 |
46 | {isPending && } 47 | {isCoachMarkOpen && } 48 | 49 | 50 |
51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /apps/client/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { HomePage } from './HomePage/HomePage'; 2 | export { NotFound } from './NotFound/NotFound'; 3 | export { WorkspacePage } from './Workspacepage/WorkspacePage'; 4 | -------------------------------------------------------------------------------- /apps/client/src/shared/api/axiosInstance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const Instance = axios.create({ 4 | baseURL: import.meta.env.VITE_SERVER_URL, 5 | timeout: 5000, 6 | headers: { 'Content-Type': 'application/json' }, 7 | }); 8 | -------------------------------------------------------------------------------- /apps/client/src/shared/api/index.ts: -------------------------------------------------------------------------------- 1 | export { Instance } from './axiosInstance'; 2 | export { WorkspaceApi } from './workspaceApi'; 3 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/arrow_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/arrow_left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/arrow_up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/code_copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/css_class_delete_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/minus_border.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/picture_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/plus_border.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/plus_green.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/spinner.svg: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/table_chart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/tag_container.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/tag_etc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/tag_link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/tag_text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/client/src/shared/assets/x_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/categoryColours.ts: -------------------------------------------------------------------------------- 1 | export const categoryColours = { 2 | containerCategory: { 3 | colour: 'FF3A61', 4 | }, 5 | textCategory: { 6 | colour: 'FFD900', 7 | }, 8 | formCategory: { 9 | colour: 'FF9821', 10 | }, 11 | tableCategory: { 12 | colour: 'B223F5', 13 | }, 14 | listCategory: { 15 | colour: '3E84FF', 16 | }, 17 | linkCategory: { 18 | colour: '3ED5FF', 19 | }, 20 | etcCategory: { 21 | colour: '00AF6F', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/createCssClassBlock.ts: -------------------------------------------------------------------------------- 1 | import { CustomFieldLabelSerializable } from '@/core/customFieldLabelSerializable'; 2 | import * as Blockly from 'blockly/core'; 3 | import { removeCssClassNamePrefix } from '../utils'; 4 | 5 | export const createCssClassBlock = (cssClassName: string) => { 6 | if (!Blockly.Blocks[cssClassName]) { 7 | Blockly.Blocks[cssClassName] = { 8 | init: function () { 9 | this.appendDummyInput().appendField( 10 | new CustomFieldLabelSerializable(removeCssClassNamePrefix(cssClassName)), 11 | 'CLASS' 12 | ); 13 | this.setOutput(true); 14 | this.setStyle(`defaultBlockCss`); 15 | this.showContextMenu = (e: PointerEvent) => { 16 | const transfromX = this.getSvgRoot().transform.baseVal[0].matrix.e; 17 | if (transfromX !== 8) { 18 | return; 19 | } 20 | const menuOptions = this.generateContextMenu(); 21 | Blockly.ContextMenu.show(e, menuOptions, this.RTL); 22 | }; 23 | }, 24 | }; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/cssCodeGenerator.ts: -------------------------------------------------------------------------------- 1 | import { TTotalCssPropertyObj } from '@/shared/types'; 2 | import { removeCssClassNamePrefix } from '../utils'; 3 | 4 | export const cssCodeGenerator = (totalCssPropertyObj: TTotalCssPropertyObj) => { 5 | let cssCode = ''; 6 | 7 | Object.keys(totalCssPropertyObj) 8 | .filter((className) => className && className.length > 0) 9 | .forEach((className) => { 10 | cssCode += `.${removeCssClassNamePrefix(className)} {\n`; 11 | Object.keys(totalCssPropertyObj[className].cssOptionObj).forEach((label) => { 12 | if ( 13 | totalCssPropertyObj[className].checkedCssPropertyObj[label] && 14 | totalCssPropertyObj[className].cssOptionObj[label].length > 0 15 | ) { 16 | cssCode += ` ${label} : ${totalCssPropertyObj[className].cssOptionObj[label]};\n`; 17 | } 18 | }); 19 | cssCode += '}\n'; 20 | }); 21 | 22 | return cssCode; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/cssStyleToolboxConfig.ts: -------------------------------------------------------------------------------- 1 | import { TToolboxConfig } from '@/shared/types'; 2 | 3 | export const cssStyleToolboxConfig: TToolboxConfig = { 4 | kind: 'categoryToolbox', 5 | contents: [], 6 | }; 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/findBlockStartLine.ts: -------------------------------------------------------------------------------- 1 | // block id를 이용하여 해당 블록이 시작하는 줄을 찾는 함수 2 | export const findBlockStartLine = (htmlCode: string, blockId: string): number => { 3 | const lines = htmlCode.split('\n'); 4 | for (let i = 0; i < lines.length; i++) { 5 | if (lines[i].includes(`data-block-id="${blockId}"`)) { 6 | return i + 1; 7 | } 8 | } 9 | return -1; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/htmlTagToolboxConfig.ts: -------------------------------------------------------------------------------- 1 | import { blockContents } from '@/shared/blockly'; 2 | 3 | export const htmlTagToolboxConfig = { 4 | kind: 'categoryToolbox', 5 | contents: [ 6 | { 7 | kind: 'category', 8 | name: '컨테이너', 9 | categorystyle: 'containerCategory', 10 | contents: blockContents.container, 11 | }, 12 | { 13 | kind: 'category', 14 | name: '텍스트', 15 | categorystyle: 'textCategory', 16 | contents: blockContents.text, 17 | }, 18 | { 19 | kind: 'category', 20 | name: '폼', 21 | categorystyle: 'formCategory', 22 | contents: blockContents.form, 23 | }, 24 | { 25 | kind: 'category', 26 | name: '표', 27 | categorystyle: 'tableCategory', 28 | contents: blockContents.table, 29 | }, 30 | { 31 | kind: 'category', 32 | name: '리스트', 33 | categorystyle: 'listCategory', 34 | contents: blockContents.list, 35 | }, 36 | { 37 | kind: 'category', 38 | name: '링크', 39 | categorystyle: 'linkCategory', 40 | contents: blockContents.link, 41 | }, 42 | { 43 | kind: 'category', 44 | name: '내용', 45 | categorystyle: 'etcCategory', 46 | contents: blockContents.etc, 47 | }, 48 | ], 49 | }; 50 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/index.ts: -------------------------------------------------------------------------------- 1 | export { categoryColours } from './categoryColours'; 2 | export { cssStyleToolboxConfig } from './cssStyleToolboxConfig'; 3 | export { defineBlocks } from './defineBlocks'; 4 | export { blockContents } from './htmlBlockContents'; 5 | export { htmlCodeGenerator } from './htmlCodeGenerator'; 6 | export { htmlTagToolboxConfig } from './htmlTagToolboxConfig'; 7 | export { initializeBlocks, initialBlocksJson } from './initBlocks'; 8 | export { initTheme } from './initTheme'; 9 | export { tabToolboxConfig } from './tabConfig'; 10 | export { createCssClassBlock } from './createCssClassBlock'; 11 | export { cssCodeGenerator } from './cssCodeGenerator'; 12 | export { generateFullCodeWithBlockId } from './htmlCodeGenerator'; 13 | export { removeBlockIdFromCode } from './htmlCodeGenerator'; 14 | export { calculateBlockLength } from './calculateBlockLength'; 15 | export { findBlockStartLine } from './findBlockStartLine'; 16 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/initTheme.ts: -------------------------------------------------------------------------------- 1 | import 'blockly/blocks'; 2 | import * as Blockly from 'blockly/core'; 3 | import { categoryColours } from '@/shared/blockly'; 4 | 5 | // 디자인에 맞춰 블록에 따라 블록 색상이 다르게 적용되도록 커스텀 theme를 만들어두었습니다. 6 | const defaultBlockStyles: { 7 | [key: string]: Partial; 8 | } = { 9 | ...Blockly.Themes.Zelos.blockStyles, 10 | defaultBlock1: { 11 | colourPrimary: '#B2DAFF', 12 | colourSecondary: '#F4F8FA', 13 | colourTertiary: '#2677C3', 14 | }, 15 | defaultBlock2: { 16 | colourPrimary: '#67B6FF', 17 | colourSecondary: 'F4F8FA', 18 | colourTertiary: '#2677C3', 19 | }, 20 | defaultBlock3: { 21 | colourPrimary: '#4195E4', 22 | colourSecondary: '#F4F8FA', 23 | colourTertiary: '#2677C3', 24 | }, 25 | defaultBlockCss: { 26 | colourPrimary: '#FFF3AD', 27 | colourSecondary: '#41505B', 28 | colourTertiary: '#FFE241', 29 | }, 30 | }; 31 | 32 | export const initTheme = Blockly.Theme.defineTheme('custom', { 33 | name: 'custom', 34 | base: Blockly.Themes.Classic, 35 | componentStyles: { 36 | workspaceBackgroundColour: '#fafafa', // 워크스페이스 배경색 37 | toolboxBackgroundColour: 'blackBackground', // 툴박스 배경색 38 | flyoutBackgroundColour: 'white', // 툴박스 플라이아웃 배경색 39 | flyoutOpacity: 1, 40 | scrollbarColour: '#000000', 41 | insertionMarkerColour: '#fff', 42 | insertionMarkerOpacity: 0.3, 43 | scrollbarOpacity: 0.001, 44 | cursorColour: '#d0d0d0', 45 | }, 46 | 47 | categoryStyles: categoryColours, 48 | blockStyles: defaultBlockStyles, 49 | }); 50 | -------------------------------------------------------------------------------- /apps/client/src/shared/blockly/tabConfig.ts: -------------------------------------------------------------------------------- 1 | import FixedFlyout from '@/core/fixedFlyout'; 2 | import { cssStyleToolboxConfig, htmlTagToolboxConfig } from '@/shared/blockly'; 3 | import StyleFlyout from '@/core/styleFlyout'; 4 | import { TTabToolboxConfig } from '@/shared/types'; 5 | 6 | export const tabToolboxConfig: TTabToolboxConfig = { 7 | tabs: { 8 | html: { 9 | label: 'HTML 태그', 10 | toolboxConfig: htmlTagToolboxConfig, 11 | flyoutRegistryName: FixedFlyout.registryName, 12 | }, 13 | css: { 14 | label: 'CSS 클래스', 15 | toolboxConfig: cssStyleToolboxConfig, 16 | flyoutRegistryName: StyleFlyout.registryName, 17 | }, 18 | }, 19 | defaultSelectedTab: 'html', 20 | }; 21 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/components/CodeContent/CodeContent.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CodeContent } from './CodeContent'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/code-highlighter/CodeContent', 7 | component: CodeContent, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | code: ` 21 | 22 | 23 |
24 |

title

25 |

content

26 |
27 | 28 | `, 29 | codeLineList: [ 30 | '', 31 | ' ', 32 | ' ', 33 | '
', 34 | '

title

', 35 | '

content

', 36 | '
', 37 | ' ', 38 | '', 39 | ], 40 | selectedBlockStartLine: 5, 41 | selectedBlockLength: 7, 42 | selectedBlockType: 'BOOLOCK_SYSTEM_html', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/components/CodeViewer/CodeViewer.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CodeViewer } from './CodeViewer'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/code-highlighter/CodeViewer', 7 | component: CodeViewer, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | code: ` 21 | 22 | 23 |
24 |

title

25 |

content

26 |
27 | 28 | `, 29 | type: 'html', 30 | theme: 'light', 31 | }, 32 | }; 33 | 34 | export const DarkThemeHTML: Story = { 35 | args: { 36 | code: ` 37 | 38 | 39 |
40 |

title

41 |

content

42 |
43 | 44 | `, 45 | type: 'html', 46 | theme: 'dark', 47 | }, 48 | }; 49 | 50 | export const LightThemeCss: Story = { 51 | args: { 52 | code: `.title { 53 | background-color: red; 54 | } 55 | .content { 56 | font-size: 16px; 57 | font-weight: bold; 58 | font-family: 'Arial'; 59 | color: #000; 60 | } 61 | `, 62 | type: 'css', 63 | theme: 'light', 64 | }, 65 | }; 66 | 67 | export const DarkThemeCss: Story = { 68 | args: { 69 | code: `.title { 70 | background-color: red; 71 | } 72 | .content { 73 | font-size: 16px; 74 | font-weight: bold; 75 | font-family: 'Arial'; 76 | color: #000; 77 | } 78 | `, 79 | type: 'css', 80 | theme: 'dark', 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/components/LineNumbers/LineNumber.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { LineNumbers } from './LineNumbers'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/code-highlighter/LineNumbers', 7 | component: LineNumbers, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | codeLineList: ['line1', 'line2', 'line3'], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/components/LineNumbers/LineNumbers.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../../styles/CodeViewer.module.css'; 2 | import { useState } from 'react'; 3 | 4 | type LineNumbersProps = { 5 | codeLineList: string[]; 6 | }; 7 | 8 | /** 9 | * 10 | * @description 11 | * 코드의 줄 수를 표시하는 컴포넌트 12 | */ 13 | export const LineNumbers = ({ codeLineList }: LineNumbersProps) => { 14 | const [hoveredLineNumber, setHoveredLineNumber] = useState(null); 15 | 16 | // 마우스 enter, leave 색상 변화 17 | const handleMouseEnter = (lineNumber: number) => { 18 | setHoveredLineNumber(lineNumber); 19 | }; 20 | 21 | const handleMouseLeave = () => { 22 | setHoveredLineNumber(null); 23 | }; 24 | 25 | return ( 26 |
27 | {codeLineList.map((_, index) => ( 28 |
handleMouseEnter(index + 1)} 31 | onMouseLeave={handleMouseLeave} 32 | className={`${styles.lineNumber} ${hoveredLineNumber === index + 1 ? styles.lineHighlight : ''}`} 33 | > 34 | {index + 1} 35 |
36 | ))} 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/index.ts: -------------------------------------------------------------------------------- 1 | export { CodeViewer } from './components/CodeViewer/CodeViewer'; 2 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/styles/CodeViewer.module.css: -------------------------------------------------------------------------------- 1 | .viewer { 2 | display: flex; 3 | font-size: 14px; 4 | height: 100%; 5 | } 6 | 7 | .scrollContainer { 8 | display: flex; 9 | width: 100%; 10 | overflow-y: auto; 11 | } 12 | 13 | .dark { 14 | background-color: #1e1e1e; 15 | color: #d4d4d4; 16 | } 17 | 18 | .lineNumbers { 19 | padding: 10px; 20 | text-align: right; 21 | color: #888888; 22 | } 23 | 24 | .lineNumber { 25 | padding: 0px 5px; 26 | } 27 | 28 | .lineHighlight { 29 | color: #02d085; 30 | } 31 | 32 | .codeContent { 33 | flex: 1; 34 | padding: 10px 0; 35 | } 36 | 37 | .codeContent .tag { 38 | color: #007acc; 39 | } 40 | 41 | .codeContent .attribute { 42 | color: #d19a66; 43 | } 44 | 45 | .codeContent .value { 46 | color: #98c379; 47 | } 48 | 49 | .codeContent .selector { 50 | color: #c678dd; 51 | } 52 | 53 | .codeContent .property { 54 | color: #61afef; 55 | } 56 | 57 | .codeContent .property-value { 58 | color: #e06c75; 59 | } 60 | 61 | @keyframes fadeIn { 62 | from { 63 | opacity: 0; 64 | } 65 | to { 66 | opacity: 1; 67 | } 68 | } 69 | 70 | .newLine { 71 | animation: fadeIn 1s ease-in-out; 72 | } 73 | 74 | .blockHighlight { 75 | background-color: #e5fbf3; 76 | } 77 | -------------------------------------------------------------------------------- /apps/client/src/shared/code-highlighter/utils/parseHighlightCss.ts: -------------------------------------------------------------------------------- 1 | export const parseHighlightCss = ( 2 | css: string, 3 | styles: Record, 4 | selectedBlockType: string | null 5 | ) => { 6 | const lines = css.split('\n'); 7 | let isWithinBlock = false; // 블록 내부인지 추적 8 | 9 | const formattedCss = lines 10 | .map((line) => { 11 | // 선택된 클래스 시작 부분 12 | if (selectedBlockType && line.includes(`.${selectedBlockType}`)) { 13 | isWithinBlock = true; 14 | return `${line}`; 15 | } 16 | 17 | // 블록 내부 유지 (닫는 중괄호까지) 18 | if (isWithinBlock && !line.includes('}')) { 19 | // 들여쓰기 공백 추가 (trim 호출 제거) 20 | return `${line}`; 21 | } 22 | 23 | // 블록 종료 24 | if (isWithinBlock && line.includes('}')) { 25 | isWithinBlock = false; 26 | return `${line}`; 27 | } 28 | 29 | // 기존 로직 유지 30 | return line 31 | .replace(/([^\s{}]+)\s*{/g, (_, selector) => { 32 | const highlightedSelector = 33 | selectedBlockType && selector.includes(`.${selectedBlockType}`) 34 | ? `${selector}` 35 | : selector; 36 | return `${highlightedSelector} {`; 37 | }) 38 | .replace(/([\w-]+):/g, (_, property) => { 39 | return `  ${property}:`; 40 | }) 41 | .replace(/:\s*([^;]+);/g, (_, value) => { 42 | return `: ${value};`; 43 | }); 44 | }) 45 | .join('\n'); 46 | 47 | // 모든 줄 앞에 공백 두 칸 추가 48 | return formattedCss 49 | .split('\n') 50 | .map((line) => ` ${line}`) 51 | .join('\n'); 52 | }; 53 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/css/useCssOptions.ts: -------------------------------------------------------------------------------- 1 | import { useCssPropsStore, useWorkspaceChangeStatusStore } from '@/shared/store'; 2 | 3 | import { debounce } from '@/shared/utils'; 4 | import { useCallback } from 'react'; 5 | 6 | export const useCssOptions = () => { 7 | const { setCheckedCssPropertyObj, setCssOptionObj, currentCssClassName } = useCssPropsStore(); 8 | const { setIsCssChanged } = useWorkspaceChangeStatusStore(); 9 | const handleCssPropertyCheckboxChange = ( 10 | property: string, 11 | isChecked: boolean, 12 | cssOption: string 13 | ) => { 14 | setIsCssChanged(true); 15 | setCheckedCssPropertyObj(currentCssClassName, property, !isChecked); 16 | if (!isChecked) { 17 | setCssOptionObj(currentCssClassName, property, cssOption); 18 | } 19 | }; 20 | 21 | const handleCssOptionChange = (property: string, value: string) => { 22 | setIsCssChanged(true); 23 | setCssOptionObj(currentCssClassName, property, value); 24 | }; 25 | 26 | const handleColorChange = useCallback( 27 | debounce((property: string, value: string) => { 28 | handleCssOptionChange(property, value); 29 | }, 200), 30 | [handleCssOptionChange] 31 | ); 32 | 33 | return { 34 | handleCssPropertyCheckboxChange, 35 | handleCssOptionChange, 36 | handleColorChange, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/css/useCssTooltip.ts: -------------------------------------------------------------------------------- 1 | import { useCssTooltipStore } from '@/shared/store'; 2 | import { useEffect } from 'react'; 3 | import { useWindowSize } from '@/shared/hooks'; 4 | 5 | export const useCssTooltip = () => { 6 | const { leftX, topY, offsetX, offsetY, setLeftX, setTopY } = useCssTooltipStore(); 7 | 8 | const { screenWidth, screenHeight } = useWindowSize(); 9 | 10 | useEffect(() => { 11 | const tooltipHeight = 40; 12 | setLeftX(offsetX); 13 | if (offsetY + tooltipHeight > screenHeight) { 14 | setTopY(-offsetY + tooltipHeight); // 높이를 벗어나는 것임 15 | } else { 16 | setTopY(offsetY); 17 | } 18 | }, [offsetX, offsetY, screenWidth, screenHeight]); 19 | 20 | return { leftX, topY }; 21 | }; 22 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/css/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { debounce } from '@/shared/utils'; 4 | 5 | export const useWindowSize = () => { 6 | const [screenWidth, setScreenWidth] = useState(window.innerWidth); 7 | const [screenHeight, setScreenHeight] = useState(window.innerHeight); 8 | 9 | useEffect(() => { 10 | const handleResize = debounce(() => { 11 | setScreenWidth(window.innerWidth); 12 | setScreenHeight(window.innerHeight); 13 | }, 200); 14 | 15 | window.addEventListener('resize', handleResize); 16 | return () => { 17 | window.removeEventListener('resize', handleResize); 18 | }; 19 | }, []); 20 | 21 | return { screenWidth, setScreenWidth, screenHeight, setScreenHeight }; 22 | }; 23 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useCreateWorkspace } from './queries/useCreateWorkspace'; 2 | export { useGetWorkspaceList } from './queries/useGetWorkspaceList'; 3 | export { useGetWorkspace } from './queries/useGetWorkspace'; 4 | export { useUpdateWorkspaceName } from './queries/useUpdateWorkspaceName'; 5 | export { useDeleteWorkspace } from './queries/useDeleteWorkspace'; 6 | export { useSaveWorkspace } from './queries/useSaveWorkspace'; 7 | export { usePostImage } from './queries/usePostImage'; 8 | export { useDeleteImage } from './queries/useDeleteImage'; 9 | 10 | export { useWindowSize } from './css/useWindowSize'; 11 | export { useCssTooltip } from './css/useCssTooltip'; 12 | export { useCssOptions } from './css/useCssOptions'; 13 | export { useCssOptionItem } from './css/useCssOptionItem'; 14 | 15 | export { workspaceKeys } from './query-key/workspaceKeys'; 16 | 17 | export { usePreventLeaveWorkspacePage } from './usePreventLeaveWorkspacePage'; 18 | export { useInfiniteScroll } from './useInfiniteScroll'; 19 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useCreateWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { createUserId, getUserId } from '@/shared/utils'; 2 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 | 4 | import { TCreatedWorkspaceDto } from '@/shared/types'; 5 | import { WorkspaceApi } from '@/shared/api'; 6 | import toast from 'react-hot-toast'; 7 | import { useLoadingStore } from '@/shared/store'; 8 | import { useNavigate } from 'react-router-dom'; 9 | import { workspaceKeys } from '@/shared/hooks'; 10 | 11 | export const useCreateWorkspace = (isSample = false) => { 12 | const workspaceApi = WorkspaceApi(); 13 | const navigate = useNavigate(); 14 | const setPending = useLoadingStore((state) => state.setIsPending); 15 | const queryClient = useQueryClient(); 16 | const { mutate } = useMutation({ 17 | mutationFn: () => { 18 | setPending(true); 19 | const userId = getUserId() || createUserId(); 20 | return workspaceApi.createWorkspace(userId, isSample); 21 | }, 22 | onSuccess: (newWorkspace) => { 23 | queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); 24 | if (!isSample) { 25 | navigate(`/workspace/${newWorkspace.newWorkspaceId}`); 26 | } 27 | }, 28 | onError: () => { 29 | toast.error('워크스페이스 생성 실패'); 30 | }, 31 | onSettled: () => { 32 | setPending(false); 33 | }, 34 | }); 35 | 36 | return { mutate }; 37 | }; 38 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useDeleteImage.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceApi } from '@/shared/api'; 2 | import { getUserId } from '@/shared/utils'; 3 | import toast from 'react-hot-toast'; 4 | import { useImageModalStore } from '@/shared/store'; 5 | import { useMutation } from '@tanstack/react-query'; 6 | 7 | export const useDeleteImage = () => { 8 | const workspaceApi = WorkspaceApi(); 9 | const userId = getUserId() || ''; 10 | const { deleteImagePath } = useImageModalStore(); 11 | 12 | const { mutate } = useMutation({ 13 | mutationFn: ({ workspaceId, imageName }: { workspaceId: string; imageName: string }) => { 14 | return workspaceApi.deleteImage(userId, workspaceId, imageName); 15 | }, 16 | onSuccess: (result) => { 17 | deleteImagePath(result.imageName); 18 | toast.success('이미지 삭제 성공'); 19 | }, 20 | onError: () => { 21 | toast.error('이미지 삭제 실패'); 22 | }, 23 | }); 24 | 25 | return { mutate }; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useDeleteWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { WorkspaceApi } from '@/shared/api'; 4 | import { createUserId, getUserId } from '@/shared/utils'; 5 | import toast from 'react-hot-toast'; 6 | import { useModalStore } from '@/shared/store'; 7 | import { workspaceKeys } from '@/shared/hooks'; 8 | 9 | export const useDeleteWorkspace = () => { 10 | const queryClient = useQueryClient(); 11 | const workspaceApi = WorkspaceApi(); 12 | const userId = getUserId() || createUserId(); 13 | const { closeModal, setIsLoading } = useModalStore(); 14 | 15 | const { mutate } = useMutation({ 16 | mutationFn: (workspaceId: string) => { 17 | setIsLoading(true); 18 | return workspaceApi.deleteWorkspace(userId, workspaceId); 19 | }, 20 | onSuccess: () => { 21 | queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); 22 | toast.success('워크스페이스 삭제 성공'); 23 | }, 24 | onError: () => { 25 | toast.error('워크스페이스 삭제 실패'); 26 | }, 27 | onSettled: () => { 28 | setIsLoading(false); 29 | closeModal(); 30 | }, 31 | }); 32 | 33 | return { mutate }; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useGetWorkspaceList.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceApi } from '@/shared/api'; 2 | import { createUserId, getUserId } from '@/shared/utils'; 3 | import { useInfiniteQuery } from '@tanstack/react-query'; 4 | import { workspaceKeys } from '@/shared/hooks'; 5 | export const useGetWorkspaceList = () => { 6 | const workspaceApi = WorkspaceApi(); 7 | 8 | const { 9 | hasNextPage, 10 | fetchNextPage, 11 | isPending, 12 | isFetchingNextPage, 13 | isError, 14 | data: workspaceList, 15 | } = useInfiniteQuery({ 16 | queryKey: workspaceKeys.list(), 17 | queryFn: async ({ pageParam }) => { 18 | const isNewUser = !getUserId(); 19 | const userId = getUserId() || createUserId(); 20 | if (isNewUser) { 21 | await workspaceApi.createWorkspace(userId, true); 22 | } 23 | return workspaceApi.getWorkspaceList(userId, pageParam); 24 | }, 25 | initialPageParam: 'null', 26 | getNextPageParam: (lastPage) => { 27 | return lastPage.pagedWorkspaceListResult?.nextCursor 28 | ? JSON.stringify(lastPage.pagedWorkspaceListResult.nextCursor) 29 | : undefined; 30 | }, 31 | select: (data) => 32 | (data.pages ?? []).flatMap((page) => page.pagedWorkspaceListResult.workspaceList), 33 | }); 34 | 35 | return { hasNextPage, fetchNextPage, isFetchingNextPage, isPending, isError, workspaceList }; 36 | }; 37 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/usePostImage.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceApi } from '@/shared/api'; 2 | import { getUserId } from '@/shared/utils'; 3 | import toast from 'react-hot-toast'; 4 | import { useImageModalStore } from '@/shared/store'; 5 | import { useMutation } from '@tanstack/react-query'; 6 | 7 | export const usePostImage = () => { 8 | const workspaceApi = WorkspaceApi(); 9 | const userId = getUserId() || ''; 10 | const { pushImagePath, setNowImage } = useImageModalStore(); 11 | const { mutate, isPending } = useMutation({ 12 | mutationFn: ({ 13 | workspaceId, 14 | imageName, 15 | image, 16 | }: { 17 | workspaceId: string; 18 | imageName: string; 19 | image: File; 20 | }) => { 21 | return workspaceApi.postImage(userId, workspaceId, imageName, image); 22 | }, 23 | onSuccess: (result) => { 24 | pushImagePath(result.imageName, result.imageUrl); 25 | setNowImage(result.imageUrl); 26 | toast.success('성공적으로 저장되었습니다.'); 27 | }, 28 | onError: () => { 29 | toast.error('저장에 실패했습니다.'); 30 | }, 31 | }); 32 | 33 | return { mutate, isPending }; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useSaveWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { TCanvas, TTotalCssPropertyObj } from '@/shared/types/workspaceType'; 2 | import { createUserId, getUserId } from '@/shared/utils'; 3 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 | 5 | import { TBlock } from '@/shared/types'; 6 | import { WorkspaceApi } from '@/shared/api'; 7 | import toast from 'react-hot-toast'; 8 | import { useImageModalStore, useWorkspaceChangeStatusStore } from '@/shared/store'; 9 | import { workspaceKeys } from '@/shared/hooks/query-key/workspaceKeys'; 10 | 11 | export const useSaveWorkspace = (workspaceId: string) => { 12 | const workspaceApi = WorkspaceApi(); 13 | const userId = getUserId() || createUserId(); 14 | const { resetChangedStatusState } = useWorkspaceChangeStatusStore(); 15 | const { imageMap } = useImageModalStore(); 16 | const queryClient = useQueryClient(); 17 | const { mutate, isPending } = useMutation({ 18 | mutationFn: ({ 19 | totalCssPropertyObj, 20 | canvas, 21 | classBlockList, 22 | cssResetStatus, 23 | thumbnail, 24 | }: { 25 | totalCssPropertyObj: TTotalCssPropertyObj; 26 | canvas: TCanvas; 27 | classBlockList: TBlock[]; 28 | cssResetStatus: boolean; 29 | thumbnail: File; 30 | }) => { 31 | return workspaceApi.saveWorkspace( 32 | userId, 33 | workspaceId, 34 | totalCssPropertyObj, 35 | canvas, 36 | classBlockList, 37 | cssResetStatus, 38 | thumbnail, 39 | imageMap 40 | ); 41 | }, 42 | onSuccess: () => { 43 | resetChangedStatusState(); 44 | queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); 45 | toast.success('성공적으로 저장되었습니다.'); 46 | }, 47 | onError: () => { 48 | toast.error('저장에 실패했습니다.'); 49 | }, 50 | }); 51 | 52 | return { mutate, isPending }; 53 | }; 54 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/queries/useUpdateWorkspaceName.ts: -------------------------------------------------------------------------------- 1 | import { createUserId, getUserId } from '@/shared/utils'; 2 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 | 4 | import { WorkspaceApi } from '@/shared/api'; 5 | import toast from 'react-hot-toast'; 6 | import { useWorkspaceStore } from '@/shared/store'; 7 | import { workspaceKeys } from '@/shared/hooks'; 8 | 9 | export const useUpdateWorkspaceName = () => { 10 | const queryClient = useQueryClient(); 11 | const workspaceApi = WorkspaceApi(); 12 | const userId = getUserId() || createUserId(); 13 | const { setName } = useWorkspaceStore(); 14 | const { mutate, isPending } = useMutation({ 15 | mutationFn: ({ workspaceId, newName }: { workspaceId: string; newName: string }) => { 16 | return workspaceApi.updateWorkspaceName(userId, workspaceId, newName); 17 | }, 18 | onSuccess: (data) => { 19 | toast.success('워크스페이스 이름이 변경되었습니다.'); 20 | setName(data.name); 21 | queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); 22 | }, 23 | onError: () => { 24 | toast.error('워크스페이스 이름 변경을 실패했습니다.'); 25 | }, 26 | }); 27 | 28 | return { mutate, isPending }; 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/query-key/workspaceKeys.ts: -------------------------------------------------------------------------------- 1 | export const workspaceKeys = { 2 | all: ['workspace'] as const, 3 | list: () => [...workspaceKeys.all, 'list'] as const, 4 | detail: (workspaceId: string) => [...workspaceKeys.all, 'detail', workspaceId] as const, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/useInfiniteScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | type useInfiniteScrollProps = { 4 | intersectionCallback: IntersectionObserverCallback; 5 | }; 6 | 7 | export const useInfiniteScroll = ({ intersectionCallback }: useInfiniteScrollProps) => { 8 | const targetRef = useRef(null); 9 | 10 | const option = { 11 | root: null, 12 | rootMargin: '0px', 13 | threshold: 0.5, 14 | }; 15 | 16 | useEffect(() => { 17 | if (!targetRef.current) { 18 | return; 19 | } 20 | const observer = new IntersectionObserver(intersectionCallback, option); 21 | observer.observe(targetRef.current); 22 | return () => { 23 | if (targetRef.current) { 24 | observer.unobserve(targetRef.current); 25 | } 26 | }; 27 | }, [intersectionCallback]); 28 | return targetRef; 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/shared/hooks/usePreventLeaveWorkspacePage.ts: -------------------------------------------------------------------------------- 1 | import { useBlocker } from 'react-router-dom'; 2 | import { useEffect } from 'react'; 3 | import { useWorkspaceChangeStatusStore } from '@/shared/store'; 4 | 5 | export const usePreventLeaveWorkspacePage = () => { 6 | const { isBlockChanged, isCssChanged } = useWorkspaceChangeStatusStore(); 7 | const blocker = useBlocker( 8 | ({ currentLocation, nextLocation }) => 9 | currentLocation.pathname !== nextLocation.pathname && (isBlockChanged || isCssChanged) 10 | ); 11 | 12 | const promptOfLeavePage = `저장하지 않은 변경사항이 있습니다. 정말로 떠나시겠습니까?`; 13 | useEffect(() => { 14 | if (blocker.state === 'blocked') { 15 | const confirmLeave = window.confirm(promptOfLeavePage); 16 | if (confirmLeave) { 17 | blocker.proceed(); 18 | } else { 19 | blocker.reset(); 20 | } 21 | } 22 | }, [blocker.state, isBlockChanged, isCssChanged]); 23 | 24 | const handleBeforeUnload = (e: Event) => { 25 | e.preventDefault(); 26 | }; 27 | 28 | const onPreventLeave = () => { 29 | window.addEventListener('beforeunload', handleBeforeUnload); 30 | }; 31 | 32 | const offPreventLeave = () => { 33 | window.removeEventListener('beforeunload', handleBeforeUnload); 34 | }; 35 | 36 | useEffect(() => { 37 | if (isBlockChanged || isCssChanged) { 38 | onPreventLeave(); 39 | } else { 40 | offPreventLeave(); 41 | } 42 | return () => { 43 | offPreventLeave(); 44 | }; 45 | }, [isBlockChanged, isCssChanged]); 46 | }; 47 | -------------------------------------------------------------------------------- /apps/client/src/shared/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web31-BooLock/18a3d1c18cb01ffe4bb1cb4cf0c6cc54249875ce/apps/client/src/shared/index.ts -------------------------------------------------------------------------------- /apps/client/src/shared/lib/example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web31-BooLock/18a3d1c18cb01ffe4bb1cb4cf0c6cc54249875ce/apps/client/src/shared/lib/example -------------------------------------------------------------------------------- /apps/client/src/shared/store/index.ts: -------------------------------------------------------------------------------- 1 | export { useLoadingStore } from './useLoadingStore'; 2 | export { useModalStore } from './useModalStore'; 3 | export { useCssPropsStore } from './useCssPropsStore'; 4 | export { useCssTooltipStore } from './useCssTooptipStore'; 5 | export { useClassBlockStore } from './useClassBlockStore'; 6 | export { useWorkspaceChangeStatusStore } from './useWorkspaceChangeStatusStore'; 7 | export { useBlocklyWorkspaceStore } from './useBlocklyWorkspaceStore'; 8 | export { useResetCssStore } from './useResetCssStore'; 9 | export { useWorkspaceStore } from './useWorkspaceStore'; 10 | export { useImageModalStore } from './useImageModalStore'; 11 | export { useIframeStore } from './useIframeStore'; 12 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useBlocklyWorkspaceStore.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | import { create } from 'zustand'; 4 | 5 | type TBlocklyWorkspace = { 6 | workspace: Blockly.WorkspaceSvg | null; 7 | 8 | setWorkspace: (workspace: Blockly.WorkspaceSvg) => void; 9 | }; 10 | 11 | export const useBlocklyWorkspaceStore = create((set) => ({ 12 | workspace: null, 13 | setWorkspace: (workspace: Blockly.WorkspaceSvg) => { 14 | set({ workspace }); 15 | }, 16 | })); 17 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useClassBlockStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { removeCssClassNamePrefix } from '../utils'; 3 | 4 | type TClassBlock = { 5 | classBlockList: string[]; 6 | addClassBlock: (newClassBlockName: string) => void; 7 | findClassBlock: (classBlockName: string) => boolean; 8 | removeClassBlock: (classBlockName: string) => void; 9 | initClassBlockList: (classList: string[]) => void; 10 | }; 11 | 12 | export const useClassBlockStore = create((set, get) => ({ 13 | classBlockList: [], 14 | addClassBlock: (newClassBlockName: string) => { 15 | set((state) => ({ 16 | classBlockList: [...state.classBlockList, removeCssClassNamePrefix(newClassBlockName)], 17 | })); 18 | }, 19 | findClassBlock: (classBlockName: string) => { 20 | const state = get(); 21 | return state.classBlockList.includes(removeCssClassNamePrefix(classBlockName)); 22 | }, 23 | removeClassBlock: (classBlockName: string) => { 24 | set((state) => ({ 25 | classBlockList: state.classBlockList.filter( 26 | (name) => name !== removeCssClassNamePrefix(classBlockName) 27 | ), 28 | })); 29 | }, 30 | initClassBlockList: (classList: string[]) => { 31 | set({ classBlockList: classList }); 32 | }, 33 | })); 34 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useCoachMarkStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TCoachMarkStore = { 4 | isCoachMarkOpen: boolean; 5 | currentStep: number; 6 | openCoachMark: () => void; 7 | closeCoachMark: () => void; 8 | setCurrentStep: (step: number) => void; 9 | }; 10 | 11 | export const useCoachMarkStore = create()((set) => ({ 12 | isCoachMarkOpen: true, 13 | currentStep: 0, 14 | openCoachMark: () => set({ isCoachMarkOpen: true, currentStep: 0 }), 15 | closeCoachMark: () => set({ isCoachMarkOpen: false, currentStep: 5 }), 16 | setCurrentStep: (step) => set({ currentStep: step }), 17 | })); 18 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useCssTooptipStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TCssTooltip = { 4 | offsetX: number; 5 | offsetY: number; 6 | leftX: number; 7 | topY: number; 8 | 9 | setOffsetX: (offsetX: number) => void; 10 | setOffsetY: (offsetY: number) => void; 11 | setLeftX: (leftX: number) => void; 12 | setTopY: (topY: number) => void; 13 | }; 14 | 15 | export const useCssTooltipStore = create((set) => ({ 16 | offsetX: -1, 17 | offsetY: -1, 18 | leftX: 0, 19 | topY: 0, 20 | setOffsetX: (offsetX) => set({ offsetX }), 21 | setOffsetY: (offsetY) => set({ offsetY }), 22 | setLeftX: (leftX) => set({ leftX }), 23 | setTopY: (topY) => set({ topY }), 24 | })); 25 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useIframeStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TIframe = { 4 | iframeRef: React.RefObject | null; 5 | setIframeRef: (ref: React.RefObject) => void; 6 | }; 7 | 8 | export const useIframeStore = create((set) => ({ 9 | iframeRef: null, 10 | setIframeRef: (ref) => set({ iframeRef: ref }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useLoadingStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TLoading = { 4 | isPending: boolean; 5 | setIsPending: (status: boolean) => void; 6 | }; 7 | 8 | export const useLoadingStore = create((set) => ({ 9 | isPending: false, 10 | setIsPending: (status) => set({ isPending: status }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useResetCssStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TResetCss = { 4 | isResetCssChecked: boolean; 5 | toggleResetCss: () => void; 6 | setIsResetCssChecked: (value: boolean) => void; 7 | }; 8 | 9 | export const useResetCssStore = create((set) => ({ 10 | isResetCssChecked: false, 11 | toggleResetCss: () => set((state) => ({ isResetCssChecked: !state.isResetCssChecked })), 12 | setIsResetCssChecked: (value) => set({ isResetCssChecked: value }), 13 | })); 14 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useWorkspaceChangeStatusStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type TWorkspaceChangeStatus = { 4 | isBlockChanged: boolean; 5 | isCssChanged: boolean; 6 | 7 | setIsBlockChanged: (isBlockChanged: boolean) => void; 8 | setIsCssChanged: (isCssChanged: boolean) => void; 9 | resetChangedStatusState: () => void; 10 | }; 11 | 12 | export const useWorkspaceChangeStatusStore = create((set) => ({ 13 | isBlockChanged: false, 14 | isCssChanged: false, 15 | setIsBlockChanged: (isBlockChanged) => set({ isBlockChanged }), 16 | setIsCssChanged: (isCssChanged) => set({ isCssChanged }), 17 | resetChangedStatusState: () => set({ isBlockChanged: false, isCssChanged: false }), 18 | })); 19 | -------------------------------------------------------------------------------- /apps/client/src/shared/store/useWorkspaceStore.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | import { create } from 'zustand'; 4 | 5 | type TWorkspace = { 6 | workspace: Blockly.WorkspaceSvg | null; 7 | canvasInfo: string; 8 | name: string; 9 | 10 | setWorkspace: (newWorkspace: Blockly.WorkspaceSvg | null) => void; 11 | setCanvasInfo: (blockInfo: string) => void; 12 | setName: (name: string) => void; 13 | }; 14 | 15 | export const useWorkspaceStore = create((set) => ({ 16 | workspace: null, 17 | canvasInfo: '', 18 | name: '워크스페이스 이름', 19 | setWorkspace: (newWorkspace: Blockly.WorkspaceSvg | null) => { 20 | set({ workspace: newWorkspace }); 21 | }, 22 | setCanvasInfo: (blockInfo: string) => set(() => ({ canvasInfo: blockInfo })), 23 | setName: (name: string) => set(() => ({ name: name })), 24 | })); 25 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/blockType.ts: -------------------------------------------------------------------------------- 1 | export type TBlockInfo = { 2 | kind: string; 3 | type: string; 4 | description: string; 5 | }; 6 | 7 | export type TBlockContents = Record< 8 | 'container' | 'text' | 'form' | 'table' | 'list' | 'link' | 'etc', 9 | TBlockInfo[] 10 | >; 11 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/cssCategoryType.ts: -------------------------------------------------------------------------------- 1 | export type TCssCategoryList = { 2 | category: TCssCategory; 3 | items: TCssCategoryItem[]; 4 | }[]; 5 | 6 | export type TCssCategory = 7 | | '레이아웃' 8 | | '박스모델' 9 | | '타이포그래피' 10 | | '배경' 11 | | '테두리' 12 | | '간격' 13 | | 'flex 속성' 14 | | 'grid 속성'; 15 | 16 | export type TCssCategoryItem = { 17 | label: string; 18 | type: 'select' | 'input' | 'color'; 19 | option?: string[]; 20 | description: string; 21 | }; 22 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/extendedType.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | export interface IExtendedIToolbox extends Blockly.IToolbox { 4 | HtmlDiv: HTMLElement; 5 | getToolboxItems: () => Blockly.IToolboxItem[]; 6 | setSelectedItem: (newItem: Blockly.IToolboxItem | null) => void; 7 | contentMap_: object; 8 | } 9 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/imageTagType.ts: -------------------------------------------------------------------------------- 1 | export type TImage = { 2 | imageName: string; 3 | imageUrl: string; 4 | }; 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | TCreatedWorkspaceDto, 3 | TGetWorkspaceResponse, 4 | TWorkspaceDto, 5 | TWorkspace, 6 | TPagedWorkspaceListResultDto, 7 | TPagedWorkspaceListResult, 8 | TTotalCssPropertyObj, 9 | TCursor, 10 | TCanvas, 11 | TState, 12 | } from './workspaceType'; 13 | 14 | export type { TCssCategory, TCssCategoryItem, TCssCategoryList } from './cssCategoryType'; 15 | export type { TBlockInfo, TBlockContents } from './blockType'; 16 | export type { IExtendedIToolbox } from './extendedType'; 17 | export type { TTabConfig, TTabsConfig, TTabToolboxConfig } from './tabType'; 18 | export type { TBlock, TToolboxConfig } from './styleToolboxType'; 19 | export type { TButtonContent } from './modalButtonType'; 20 | export type { TImage } from './imageTagType'; 21 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/modalButtonType.ts: -------------------------------------------------------------------------------- 1 | export type TButtonContent = { 2 | name: string; 3 | func: () => void; 4 | type: 'neutral' | 'danger'; 5 | isDisabled?: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/styleToolboxType.ts: -------------------------------------------------------------------------------- 1 | export type TBlock = { kind: string; type: string; enabled: boolean }; 2 | 3 | export type TToolboxConfig = { 4 | kind: string; 5 | contents: TBlock[]; 6 | }; 7 | -------------------------------------------------------------------------------- /apps/client/src/shared/types/tabType.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | export type TTabConfig = { 4 | label: string; 5 | toolboxConfig: Blockly.utils.toolbox.ToolboxInfo; 6 | flyoutRegistryName?: string; 7 | }; 8 | 9 | export type TTabsConfig = Record; 10 | 11 | export type TTabToolboxConfig = { 12 | tabs: TTabsConfig; 13 | defaultSelectedTab?: string; 14 | }; 15 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/button/CircleButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CircleButton } from './CircleButton'; 4 | import PlusIcon from '@/shared/assets/plus.svg?react'; 5 | import { action } from '@storybook/addon-actions'; 6 | 7 | const meta: Meta = { 8 | title: 'shared/ui/button/CircleButton', 9 | component: CircleButton, 10 | parameters: { 11 | layout: 'centered', 12 | }, 13 | tags: ['autodocs'], 14 | }; 15 | 16 | export default meta; 17 | 18 | type Story = StoryObj; 19 | 20 | export const Default: Story = { 21 | args: { 22 | children: , 23 | width: 'w-10', 24 | height: 'h-10', 25 | disable: false, 26 | onClick: action('버튼 클릭'), 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/button/CircleButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type CircleButtonProps = { 4 | children: ReactNode | string; 5 | className?: string; 6 | width?: string; 7 | height?: string; 8 | onClick: () => void; 9 | disable?: boolean; 10 | variant?: 'filled' | 'outlined'; 11 | }; 12 | 13 | /** 14 | * 15 | * @description 16 | * 원형 버튼 재사용 컴포넌트 17 | */ 18 | export const CircleButton = ({ 19 | children, 20 | className, 21 | width, 22 | height, 23 | onClick, 24 | disable = false, 25 | variant = 'filled', 26 | }: CircleButtonProps) => { 27 | const baseClasses = `flex items-center justify-center rounded-full disabled:cursor-not-allowed ${width} ${height}`; 28 | const filledClasses = `bg-green-500 text-green-100 hover:border hover:border-green-500 hover:bg-green-100 hover:text-green-500 disabled:border-green-300 disabled:bg-green-300`; 29 | const outlinedClasses = `border border-gray-100 text-gray-300 hover:text-gray-500 hover:border-gray-300`; 30 | 31 | const variantClasses = variant === 'filled' ? filledClasses : outlinedClasses; 32 | 33 | return ( 34 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/button/SquareButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { SquareButton } from './SquareButton'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'shared/ui/button/SquareButton', 8 | component: SquareButton, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = { 20 | args: { 21 | children:

버튼

, 22 | variant: 'neutral', 23 | onClick: action('버튼 클릭'), 24 | isDisabled: false, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/button/SquareButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type SquareButtonProps = { 4 | children: ReactNode; 5 | variant?: 'neutral' | 'danger'; 6 | onClick: () => void; 7 | isDisabled?: boolean; 8 | }; 9 | 10 | /** 11 | * 12 | * @description 13 | * 사각형 버튼 재사용 컴포넌트 14 | */ 15 | export const SquareButton = ({ 16 | children, 17 | variant = 'neutral', 18 | onClick, 19 | isDisabled = false, 20 | }: SquareButtonProps) => { 21 | const colorClasses = 22 | variant === 'danger' 23 | ? 'bg-red-500 hover:bg-red-600 text-white' 24 | : 'bg-gray-50 hover:bg-gray-100 text-gray-300'; 25 | return ( 26 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/error/ErrorContent.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ErrorContent } from './ErrorContent'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/ui/error/ErrorContent', 7 | component: ErrorContent, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | decorators: [ 12 | (Story) => { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | }, 19 | ], 20 | tags: ['autodocs'], 21 | }; 22 | 23 | export default meta; 24 | 25 | type Story = StoryObj; 26 | 27 | export const Default: Story = { 28 | args: { 29 | description: '에러아님 ㅋ', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/error/ErrorContent.tsx: -------------------------------------------------------------------------------- 1 | import { HomeHeader } from '@/widgets'; 2 | 3 | type ErrorContentProps = { 4 | description: string; 5 | }; 6 | 7 | export const ErrorContent = ({ description }: ErrorContentProps) => { 8 | return ( 9 | <> 10 | 11 |
12 | not_found 18 |

19 | {description} 20 |

21 |
22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { CircleButton } from './button/CircleButton'; 2 | export { SquareButton } from './button/SquareButton'; 3 | 4 | export { Logo } from './logo/Logo'; 5 | 6 | export { ModalConfirm } from './modal/ModalConfirm'; 7 | 8 | export { ToasterWithMax } from './toast/ToasterWithMax'; 9 | 10 | export { Loading } from './loading/Loading'; 11 | export { Spinner } from './loading/Spinner'; 12 | 13 | export { SkeletonWorkspace } from './skeleton/SkeletonWorkspace'; 14 | export { SkeletonWorkspaceList } from './skeleton/SkeletonWorkspaceList'; 15 | 16 | export { Select, SelectSize } from './select/Select'; 17 | export type { TOption } from './select/Select'; 18 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/loading/Loading.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Loading } from './Loading'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/ui/loading/Loading', 7 | component: Loading, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | height: 10, 21 | width: 120, 22 | color: '#02D085', 23 | }, 24 | render: (args) => ( 25 |
26 | 27 |
28 | ), 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { BarLoader } from 'react-spinners'; 2 | 3 | type LoadingProps = { 4 | height?: number; 5 | width?: number; 6 | color?: string; 7 | }; 8 | 9 | /** 10 | * 11 | * @description 12 | * 막대 로딩 컴포넌트 13 | */ 14 | export const Loading = ({ height = 10, width = 120, color = '#02D085' }: LoadingProps) => { 15 | return ( 16 |
17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/loading/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Spinner } from './Spinner'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/ui/loading/Spinner', 7 | component: Spinner, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | width: 4, 21 | height: 4, 22 | foregroundColor: 'green500', 23 | backgroundColor: 'gray200', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/loading/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { background, foreground, h, w } from '@/shared/utils'; 2 | 3 | import SpinIcon from '@/shared/assets/spinner.svg?react'; 4 | 5 | type SpinnerProps = { 6 | width: keyof typeof w; 7 | height: keyof typeof h; 8 | foregroundColor: keyof typeof foreground; 9 | backgroundColor: keyof typeof background; 10 | }; 11 | 12 | /** 13 | * @description 14 | * 스피너 컴포넌트 15 | * width, height, foregroundColor, backgroundColor에 들어가는 값은 spinnerStyle.ts에 정의된 객체의 키값만 들어갈 수 있습니다. 16 | */ 17 | export const Spinner = ({ width, height, foregroundColor, backgroundColor }: SpinnerProps) => { 18 | return ( 19 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/logo/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Logo } from './Logo'; 4 | import { action } from '@storybook/addon-actions'; 5 | 6 | const meta: Meta = { 7 | title: 'shared/ui/logo/Logo', 8 | component: Logo, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | (Story) => { 14 | const handleClick = (event: React.MouseEvent) => { 15 | event.preventDefault(); 16 | action('logo-clicked')({ pathname: '/' }); 17 | }; 18 | return ( 19 |
20 | 21 |
22 | ); 23 | }, 24 | ], 25 | tags: ['autodocs'], 26 | }; 27 | 28 | export default meta; 29 | 30 | type Story = StoryObj; 31 | 32 | export const BlackLogo: Story = { 33 | args: { 34 | isBlack: false, 35 | }, 36 | }; 37 | 38 | export const WhiteLogo: Story = { 39 | args: { 40 | isBlack: true, 41 | }, 42 | parameters: { 43 | backgrounds: { 44 | default: 'black', 45 | values: [{ name: 'black', value: '#000000' }], 46 | }, 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import BlackLogoText from '@/shared/assets/boolock_logo_black.svg?react'; 2 | import { Link } from 'react-router-dom'; 3 | import WhiteLogoText from '@/shared/assets/boolock_logo_white.svg?react'; 4 | 5 | type LogoProps = { 6 | isBlack: boolean; 7 | }; 8 | 9 | /** 10 | * 11 | * @description 12 | * 로고 컴포넌트 (흰색/검은색), 클릭 시 홈페이지로 이동 13 | */ 14 | export const Logo = ({ isBlack }: LogoProps) => { 15 | return ( 16 | 17 |
18 | Boolock Logo 24 | {isBlack ? : } 25 |
26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/modal/ModalConfirm.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ModalConfirm } from './ModalConfirm'; 4 | import { useState } from 'react'; 5 | 6 | const meta: Meta = { 7 | title: 'shared/ui/modal/ModalConfirm', 8 | component: ModalConfirm, 9 | parameters: { 10 | layout: 'centered', 11 | docs: { 12 | description: { 13 | component: '모달 재사용 컴포넌트', 14 | }, 15 | }, 16 | }, 17 | tags: ['autodocs'], 18 | }; 19 | 20 | export default meta; 21 | 22 | type Story = StoryObj; 23 | 24 | export const Default: Story = { 25 | args: { 26 | isOpen: false, 27 | }, 28 | render: (args) => { 29 | const [isOpen, setIsOpen] = useState(args.isOpen); 30 | return ( 31 |
32 | 33 | 34 |
35 |

모달창입니다.

36 | 부덕이 42 | 43 |
44 |
45 |
46 | ); 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/modal/ModalConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | type ModalConfirmProps = { 5 | children: ReactNode; 6 | isOpen: boolean; 7 | }; 8 | 9 | export const ModalConfirm = ({ children, isOpen }: ModalConfirmProps) => { 10 | if (!isOpen) return null; 11 | 12 | return createPortal( 13 |
14 |
{children}
15 |
, 16 | document.body 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/select/Select.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Select } from './Select'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/ui/Select/Select', 7 | component: Select, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | options: [ 21 | { value: '1', label: 'Option 1' }, 22 | { value: '2', label: 'Option 2' }, 23 | { value: '3', label: 'Option 3' }, 24 | ], 25 | value: '', 26 | placeholder: 'Select an option', 27 | onChange: (value: string) => console.log(value), 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/skeleton/SkeletonWorkspace.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import { SkeletonWorkspace } from './SkeletonWorkspace'; 3 | 4 | const meta: Meta = { 5 | title: 'shared/ui/skeleton/SkeletonWorkspace', 6 | component: SkeletonWorkspace, 7 | parameters: { 8 | layout: 'centered', 9 | }, 10 | tags: ['autodocs'], 11 | }; 12 | 13 | export default meta; 14 | 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/skeleton/SkeletonWorkspace.tsx: -------------------------------------------------------------------------------- 1 | import PictureIcon from '@/shared/assets/picture_icon.svg?react'; 2 | 3 | /** 4 | * 5 | * @description 6 | * 워크스페이스 스켈레톤 UI 컴포넌트 7 | */ 8 | export const SkeletonWorkspace = () => { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/skeleton/SkeletonWorkspaceList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import { SkeletonWorkspaceList } from './SkeletonWorkspaceList'; 3 | 4 | const meta: Meta = { 5 | title: 'shared/ui/skeleton/SkeletonWorkspaceList', 6 | component: SkeletonWorkspaceList, 7 | parameters: { 8 | layout: 'centered', 9 | }, 10 | tags: ['autodocs'], 11 | }; 12 | 13 | export default meta; 14 | 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/skeleton/SkeletonWorkspaceList.tsx: -------------------------------------------------------------------------------- 1 | import { SkeletonWorkspace } from '@/shared/ui/skeleton/SkeletonWorkspace'; 2 | 3 | /** 4 | * 5 | * @description 6 | * 여러 개의 워크스페이스 스켈레톤 UI 컴포넌트 7 | */ 8 | export const SkeletonWorkspaceList = ({ skeletonNum }: { skeletonNum: number }) => { 9 | return ( 10 | <> 11 | {new Array(skeletonNum).fill(0).map((_, idx) => { 12 | return ; 13 | })} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/toast/ToasterWithMax.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import toast from 'react-hot-toast'; 3 | import { ToasterWithMax } from './ToasterWithMax'; 4 | 5 | const meta: Meta = { 6 | title: 'shared/ui/toast/ToasterWithMax', 7 | component: ToasterWithMax, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | render: () => ( 20 | <> 21 | 22 |
23 | 29 |
30 | 31 | ), 32 | }; 33 | 34 | export const Success: Story = { 35 | render: () => ( 36 | <> 37 | 38 |
39 | 45 |
46 | 47 | ), 48 | }; 49 | 50 | export const Fail: Story = { 51 | render: () => ( 52 | <> 53 | 54 |
55 | 61 |
62 | 63 | ), 64 | }; 65 | -------------------------------------------------------------------------------- /apps/client/src/shared/ui/toast/ToasterWithMax.tsx: -------------------------------------------------------------------------------- 1 | import toast, { Toaster, useToasterStore } from 'react-hot-toast'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | /** 5 | * 6 | * @description 7 | * 최대 개수 제한이 있는 토스트 컴포넌트 8 | */ 9 | export const ToasterWithMax = () => { 10 | const { toasts } = useToasterStore(); 11 | const [toastLimit] = useState(1); 12 | 13 | useEffect(() => { 14 | toasts 15 | .filter((toast) => toast.visible) 16 | .filter((_, idx) => idx >= toastLimit) 17 | .forEach((t) => toast.dismiss(t.id)); 18 | }, [toasts, toastLimit]); 19 | 20 | return ; 21 | }; 22 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/boolockConstants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | *클래스명 지정 시 블록타입과 이름이 같으면 해당 블록 타입에 대한 블록 모양이 생성되는 오류가 있습니다. 3 | *이를 해결하기 위해 모든 블록타입을 지정할 때는 해당 함수를 통해 PreviousTypeName을 붙이게 하였습니다. 4 | *앞으로 블록 타입을 지정할때는 꼭..! 이 함수를 이용해서 블록 타입명을 지정해주세요. 5 | */ 6 | export const PREVIOUS_TYPE_NAME = 'BOOLOCK_SYSTEM_'; 7 | export const CSS_CLASS_PREFIX = 'CSS_'; 8 | 9 | export const addPreviousTypeName = (type: string) => { 10 | return `${PREVIOUS_TYPE_NAME}${type}`; 11 | }; 12 | 13 | export const removePreviousTypeName = (type: string): string => { 14 | if (type.startsWith(PREVIOUS_TYPE_NAME)) { 15 | return type.slice(PREVIOUS_TYPE_NAME.length); 16 | } 17 | return type; 18 | }; 19 | 20 | export const addPrefixToCssClassName = (className: string) => { 21 | return className.startsWith(CSS_CLASS_PREFIX) ? className : `${CSS_CLASS_PREFIX}${className}`; 22 | }; 23 | 24 | export const removeCssClassNamePrefix = (className: string) => { 25 | return className.startsWith(CSS_CLASS_PREFIX) 26 | ? className.replace(CSS_CLASS_PREFIX, '') 27 | : className; 28 | }; 29 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/coachMarkContent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type CoachMarkStep = { 4 | title: string; 5 | content: ReactNode; 6 | }; 7 | 8 | export const coachMarkContent: CoachMarkStep[] = [ 9 | { 10 | title: 'HTML 태그 블록 조립하기', 11 | content: ( 12 | <> 13 | 오른쪽 HTML 태그 탭에서 블록을 가져와
14 | 작업 공간에서 조립할 수 있어요 15 | 16 | ), 17 | }, 18 | { 19 | title: 'CSS 클래스 블록 생성 후 조립하기', 20 | content: ( 21 | <> 22 | 원하는 CSS 클래스 블록을 생성할 수 있어요. 23 |
24 | 생성된 블록은 HTML 블록에 조립할 수 있어요 25 | 26 | ), 27 | }, 28 | { 29 | title: '스타일 속성 추가하기', 30 | content: ( 31 | <> 32 | 생성한 CSS 클래스 블록을 선택해
33 | 원하는 34 | 스타일 속성을 추가할 수 있어요 35 | 36 | ), 37 | }, 38 | { 39 | title: '미리보기와 코드 확인하기', 40 | content: ( 41 | <> 42 | 미리보기 탭에서는 블록 코딩으로 만든 화면을, 43 |
44 | HTML/CSS 탭에서는 코드를 확인할 수 있어요. 45 | 46 | ), 47 | }, 48 | { 49 | title: '저장하고 불러오기', 50 | content: ( 51 | <> 52 | 저장하지 않고 나가면 블록이 사라져요.
53 | 변경 사항은 되돌리거나 다시 적용할 수 54 | 있어요. 55 | 56 | ), 57 | }, 58 | ]; 59 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/cssClassName.ts: -------------------------------------------------------------------------------- 1 | type TClassName = string; 2 | 3 | export const validateClassNameStart = (className: TClassName) => /^[a-zA-Z_-]/.test(className); 4 | 5 | export const validateClassNameBody = (className: TClassName) => /^.[a-zA-Z0-9_-]*$/.test(className); 6 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/dateFormat.ts: -------------------------------------------------------------------------------- 1 | export const formatRelativeOrAbsoluteDate = (dateString: string) => { 2 | const date = new Date(dateString); 3 | const now = new Date(); 4 | const rtf = new Intl.RelativeTimeFormat('ko', { numeric: 'auto' }); 5 | const diffMinutes = Math.floor((date.getTime() - now.getTime()) / (1000 * 60)); 6 | const diffHours = Math.floor(diffMinutes / 60); 7 | if (diffMinutes > -60) { 8 | return rtf.format(diffMinutes, 'minute'); 9 | } 10 | 11 | if (diffHours > -24) { 12 | return rtf.format(diffHours, 'hour'); 13 | } 14 | return new Intl.DateTimeFormat('ko', { 15 | year: 'numeric', 16 | month: '2-digit', 17 | day: '2-digit', 18 | }).format(date); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = any>(fn: T, delay: number) => { 2 | let timeout: ReturnType; 3 | return (...args: Parameters) => { 4 | let result: any; 5 | if (timeout) { 6 | clearTimeout(timeout); 7 | } 8 | timeout = setTimeout(() => { 9 | result = fn(...args); 10 | }, delay); 11 | return result; 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/exportPreviewHtml.ts: -------------------------------------------------------------------------------- 1 | import { useIframeStore, useWorkspaceStore } from '@/shared/store'; 2 | 3 | import { IFRAME_ERROR_MESSAGE } from '@/shared/utils'; 4 | 5 | export const exportPreviewHtml = () => { 6 | const previewIframe = useIframeStore.getState().iframeRef?.current; 7 | const workspaceName = useWorkspaceStore.getState().name; 8 | 9 | if (!previewIframe) { 10 | throw new Error(IFRAME_ERROR_MESSAGE.SELECT_PREVIEW_TAB); 11 | } 12 | 13 | const previewDocument = previewIframe?.contentDocument || previewIframe?.contentWindow?.document; 14 | if (!previewDocument) { 15 | throw new Error(IFRAME_ERROR_MESSAGE.FAIL_TO_SAVE); 16 | } 17 | 18 | const previewHtml = previewDocument.documentElement.outerHTML; 19 | 20 | const blob = new Blob([previewHtml], { type: 'text/html' }); 21 | 22 | const url = URL.createObjectURL(blob); 23 | 24 | const downloadLink = document.createElement('a'); 25 | downloadLink.href = url; 26 | downloadLink.download = `${workspaceName}.html`; 27 | downloadLink.click(); 28 | URL.revokeObjectURL(url); 29 | return false; 30 | }; 31 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/iframeErrorMessage.ts: -------------------------------------------------------------------------------- 1 | export const IFRAME_ERROR_MESSAGE = { 2 | SELECT_PREVIEW_TAB: '미리보기 탭을 선택해주세요.', 3 | FAIL_TO_SAVE: '저장에 실패했습니다.', 4 | }; 5 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { CATEGORY_ICONS } from './categoryIcons'; 2 | export { getUserId, createUserId } from './userId'; 3 | export { formatRelativeOrAbsoluteDate } from './dateFormat'; 4 | export { w, h, foreground, background } from './spinnerStyle'; 5 | export { 6 | addPreviousTypeName, 7 | removePreviousTypeName, 8 | PREVIOUS_TYPE_NAME, 9 | addPrefixToCssClassName, 10 | removeCssClassNamePrefix, 11 | CSS_CLASS_PREFIX, 12 | } from './boolockConstants'; 13 | export { debounce } from './debounce'; 14 | export { cssCategoryList } from './cssCategoryList'; 15 | export { hasField } from './typeGuard'; 16 | export { capturePreview } from './capturePreview'; 17 | export { coachMarkContent } from './coachMarkContent'; 18 | export { IFRAME_ERROR_MESSAGE } from './iframeErrorMessage'; 19 | export { exportPreviewHtml } from './exportPreviewHtml'; 20 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/resetCss.ts: -------------------------------------------------------------------------------- 1 | export const resetCss = ` 2 | html, body, div, span, header, section, nav, main, article, footer, p, strong, 3 | h1, h2, h3, h4, h5, h6, small, br, em, i, blockquote, hr, input, button, 4 | form, option, textarea, select, fieldset, legend, label, td, tr, th, 5 | caption, table, ul, ol, li, a { 6 | margin: 0; 7 | padding: 0; 8 | border: 0; 9 | font-size: 100%; 10 | font: inherit; 11 | vertical-align: baseline; 12 | } 13 | 14 | article, header, section, nav, main, footer { 15 | display: block; 16 | } 17 | 18 | body { 19 | line-height: 1; 20 | } 21 | 22 | ol, ul { 23 | list-style: none; 24 | } 25 | 26 | blockquote, q { 27 | quotes: none; 28 | } 29 | 30 | blockquote:before, blockquote:after, 31 | q:before, q:after { 32 | content: ''; 33 | content: none; 34 | } 35 | 36 | table { 37 | border-collapse: collapse; 38 | border-spacing: 0; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/spinnerStyle.ts: -------------------------------------------------------------------------------- 1 | export const w = { 2 | 1: 'w-1', 3 | 2: 'w-2', 4 | 3: 'w-3', 5 | 4: 'w-4', 6 | 5: 'w-5', 7 | 6: 'w-6', 8 | 7: 'w-7', 9 | 8: 'w-8', 10 | 9: 'w-9', 11 | 10: 'w-10', 12 | }; 13 | 14 | export const h = { 15 | 1: 'h-1', 16 | 2: 'h-2', 17 | 3: 'h-3', 18 | 4: 'h-4', 19 | 5: 'h-5', 20 | 6: 'h-6', 21 | 7: 'h-7', 22 | 8: 'h-8', 23 | 9: 'h-9', 24 | 10: 'h-10', 25 | }; 26 | 27 | export const foreground = { 28 | grayWhite: 'fill-gray-white', 29 | gray200: 'fill-gray-200', 30 | green500: 'fill-green-500', 31 | }; 32 | 33 | export const background = { 34 | gray200: 'text-gray-200', 35 | }; 36 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/tags.ts: -------------------------------------------------------------------------------- 1 | export const container = ['div', 'span', 'header', 'section', 'nav', 'main', 'article', 'footer']; 2 | export const text = [ 3 | 'p', 4 | 'strong', 5 | 'h1', 6 | 'h2', 7 | 'h3', 8 | 'h4', 9 | 'h5', 10 | 'h6', 11 | 'small', 12 | 'br', 13 | 'em', 14 | 'i', 15 | 'blockquote', 16 | 'hr', 17 | ]; 18 | export const form = [ 19 | 'input', 20 | 'button', 21 | 'form', 22 | 'option', 23 | 'textarea', 24 | 'select', 25 | 'fieldset', 26 | 'legend', 27 | 'label', 28 | ]; 29 | export const table = ['td', 'tr', 'th', 'caption', 'table']; 30 | export const list = ['ul', 'ol', 'li']; 31 | export const link = ['a']; 32 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/typeGuard.ts: -------------------------------------------------------------------------------- 1 | import * as Blockly from 'blockly/core'; 2 | 3 | export function hasField( 4 | inputField: Blockly.blockRendering.Measurable 5 | ): inputField is Blockly.blockRendering.Measurable & { field: Blockly.Field } { 6 | return 'field' in inputField && inputField.field instanceof Blockly.Field; 7 | } 8 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/userId.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | export const getUserId = () => { 4 | const userId = localStorage.getItem('userId'); 5 | return userId; 6 | }; 7 | 8 | export const createUserId = () => { 9 | const newUserId = v4(); 10 | localStorage.setItem('userId', newUserId); 11 | return newUserId; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/client/src/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg?react' { 2 | const value: React.FunctionComponent>; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /apps/client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/Banner/Banner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Banner } from './Banner'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/home/Banner', 7 | component: Banner, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: '메인홈페이지 배너', 13 | }, 14 | }, 15 | }, 16 | tags: ['autodocs'], 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/EmptyWorkspace/EmptyWorkspace.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { EmptyWorkspace } from './EmptyWorkspace'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/home/EmptyWorkspace', 7 | component: EmptyWorkspace, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/EmptyWorkspace/EmptyWorkspace.tsx: -------------------------------------------------------------------------------- 1 | import { HoveredEmptyWorkspace, NotHoveredEmptyWorkspace } from '@/entities'; 2 | 3 | import { useState } from 'react'; 4 | 5 | /** 6 | * 7 | * @description 8 | * 빈 워크스페이스 컴포넌트 9 | */ 10 | export const EmptyWorkspace = () => { 11 | const [isHovered, setIsHovered] = useState(false); 12 | 13 | return ( 14 |
setIsHovered(true)} 17 | onMouseLeave={() => setIsHovered(false)} 18 | > 19 |
20 | {isHovered ? : } 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/HomeHeader/HomeHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { HomeHeader } from './HomeHeader'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/home/HomeHeader', 7 | component: HomeHeader, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | // propsname: value, 21 | }, 22 | }; 23 | 24 | export const Dark: Story = { 25 | args: { 26 | isBlack: true, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/HomeHeader/HomeHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from '@/shared/ui'; 2 | 3 | /** 4 | * 5 | * @description 6 | * 홈페이지 헤더 컴포넌트 7 | */ 8 | export const HomeHeader = () => { 9 | return ( 10 |
13 |
14 |
15 | 16 |
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceContainer } from './WorkspaceContainer'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/home/WorkspaceContainer', 7 | component: WorkspaceContainer, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | // propsname: value, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceContainer/WorkspaceContainer.tsx: -------------------------------------------------------------------------------- 1 | import { EmptyWorkspace, WorkspaceGrid, WorkspaceHeader, WorkspaceList } from '@/widgets'; 2 | import { useGetWorkspaceList, useInfiniteScroll } from '@/shared/hooks'; 3 | 4 | import { SkeletonWorkspaceList } from '@/shared/ui'; 5 | import { WorkspaceLoadError } from '@/entities'; 6 | 7 | /** 8 | * 9 | * @description 10 | * 워크스페이스 헤더와 그리드를 감싸는 컨테이너 컴포넌트 11 | */ 12 | export const WorkspaceContainer = () => { 13 | const { hasNextPage, fetchNextPage, isPending, isFetchingNextPage, isError, workspaceList } = 14 | useGetWorkspaceList(); 15 | 16 | const fetchCallback: IntersectionObserverCallback = (entries, observer) => { 17 | entries.forEach((entry) => { 18 | if (entry.isIntersecting && hasNextPage) { 19 | fetchNextPage(); 20 | observer.unobserve(entry.target); 21 | } 22 | }); 23 | }; 24 | 25 | const nextFetchTargetRef = useInfiniteScroll({ intersectionCallback: fetchCallback }); 26 | 27 | return ( 28 |
29 | 30 | {isPending && ( 31 | 32 | 33 | 34 | )} 35 | {isError ? ( 36 | 37 | ) : ( 38 | workspaceList && 39 | (workspaceList.length === 0 ? ( 40 | 41 | ) : ( 42 | 43 | 44 | {isFetchingNextPage && } 45 | 46 | )) 47 | )} 48 | {!isPending && !isFetchingNextPage && hasNextPage && ( 49 |
50 | )} 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceGrid/WorkspaceGrid.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | /** 4 | * 5 | * @description 6 | * 워크스페이스 그리드 컴포넌트 7 | */ 8 | export const WorkspaceGrid = ({ children }: PropsWithChildren) => { 9 | return ( 10 |
11 |
12 | {children} 13 |
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceHeader/WorkspaceHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceHeader } from './WorkspaceHeader'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/home/WorkspaceHeader', 7 | component: WorkspaceHeader, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | decorators: [ 12 | (Story) => ( 13 |
14 | 15 |
16 | ), 17 | ], 18 | tags: ['autodocs'], 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | args: { 27 | // propsname: value, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceHeader/WorkspaceHeader.tsx: -------------------------------------------------------------------------------- 1 | import { WorkspaceAddBtn, WorkspaceSampleButton } from '@/entities'; 2 | 3 | /** 4 | * 5 | * @description 6 | * 워크스페이스 헤더 컴포넌트 7 | */ 8 | export const WorkspaceHeader = () => { 9 | return ( 10 |
11 |
12 |

워크스페이스

13 | 14 |
15 | 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceList/WorkspaceList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceList } from './WorkspaceList'; 4 | import { createUserId, getUserId } from '@/shared/utils'; 5 | import { v4 } from 'uuid'; 6 | 7 | const meta: Meta = { 8 | title: 'widgets/home/WorkspaceList', 9 | component: WorkspaceList, 10 | parameters: { 11 | layout: 'fullscreen', 12 | }, 13 | decorators: [ 14 | (Story) => ( 15 |
16 | 17 |
18 | ), 19 | ], 20 | tags: ['autodocs'], 21 | }; 22 | 23 | export default meta; 24 | 25 | type Story = StoryObj; 26 | 27 | export const Default: Story = { 28 | args: { 29 | workspaceList: [ 30 | { 31 | name: 'Workspace 1', 32 | updated_at: new Date().toISOString(), 33 | user_id: getUserId() || createUserId(), 34 | workspace_id: v4(), 35 | isCssReset: false, 36 | thumbnail: '', 37 | totalTotalCssPropertyObj: { 38 | example: { 39 | checkedCssPropertyObj: {}, 40 | cssOptionObj: {}, 41 | }, 42 | }, 43 | }, 44 | ], 45 | }, 46 | }; 47 | 48 | export const MultipleItems: Story = { 49 | args: { 50 | workspaceList: Array.from({ length: 4 }).map((_, idx) => { 51 | return { 52 | name: `Workspace ${idx}`, 53 | updated_at: new Date().toISOString(), 54 | user_id: getUserId() || createUserId(), 55 | workspace_id: v4(), 56 | isCssReset: false, 57 | thumbnail: '', 58 | totalTotalCssPropertyObj: { 59 | example: { 60 | checkedCssPropertyObj: {}, 61 | cssOptionObj: {}, 62 | }, 63 | }, 64 | }; 65 | }), 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceList/WorkspaceList.tsx: -------------------------------------------------------------------------------- 1 | import { TWorkspace } from '@/shared/types'; 2 | import { WorkspaceItem } from '@/entities'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | type workspaceListProps = { 6 | workspaceList: Array; 7 | }; 8 | 9 | /** 10 | * 11 | * @description 12 | * 워크스페이스 목록 컴포넌트 13 | */ 14 | export const WorkspaceList = ({ workspaceList }: workspaceListProps) => { 15 | const navigate = useNavigate(); 16 | return ( 17 | <> 18 | {workspaceList.map((workspace) => ( 19 | { 26 | navigate(`/workspace/${workspace.workspace_id}`); 27 | }} 28 | /> 29 | ))} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/client/src/widgets/home/WorkspaceModal/WorkspaceModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceModal } from './WorkspaceModal'; 4 | import { action } from '@storybook/addon-actions'; 5 | import { useEffect } from 'react'; 6 | import { useModalStore } from '@/shared/store'; 7 | 8 | const meta: Meta = { 9 | title: 'widgets/home/WorkspaceModal', 10 | component: WorkspaceModal, 11 | parameters: { 12 | layout: 'centered', 13 | }, 14 | decorators: [ 15 | (Story) => { 16 | const { 17 | setModalContent, 18 | setHandleModalCloseButton, 19 | setHandleModalConfirmButton, 20 | openModal, 21 | closeModal, 22 | } = useModalStore(); 23 | 24 | useEffect(() => { 25 | setModalContent('워크스페이스 관련 모달창입니다'); 26 | setHandleModalCloseButton(() => { 27 | action('closeModal')(); 28 | closeModal(); 29 | }); 30 | setHandleModalConfirmButton(() => { 31 | action('confirmModal')(); 32 | closeModal(); 33 | }); 34 | }, []); 35 | 36 | return ( 37 | <> 38 | 41 | 42 | 43 | ); 44 | }, 45 | ], 46 | tags: ['autodocs'], 47 | }; 48 | 49 | export default meta; 50 | 51 | type Story = StoryObj; 52 | 53 | export const Default: Story = { 54 | args: { 55 | // propsname: value, 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /apps/client/src/widgets/index.ts: -------------------------------------------------------------------------------- 1 | export { Banner } from './home/Banner/Banner'; 2 | export { HomeHeader } from './home/HomeHeader/HomeHeader'; 3 | export { WorkspaceList } from './home/WorkspaceList/WorkspaceList'; 4 | export { WorkspaceHeader } from './home/WorkspaceHeader/WorkspaceHeader'; 5 | export { EmptyWorkspace } from './home/EmptyWorkspace/EmptyWorkspace'; 6 | export { WorkspaceGrid } from './home/WorkspaceGrid/WorkspaceGrid'; 7 | export { WorkspaceContainer } from './home/WorkspaceContainer/WorkspaceContainer'; 8 | export { WorkspaceModal } from './home/WorkspaceModal/WorkspaceModal'; 9 | 10 | export { PreviewBox } from './workspace/PreviewBox/PreviewBox'; 11 | export { CoachMark } from './workspace/CoachMark/CoachMark'; 12 | export { WorkspaceContent } from './workspace/WorkspaceContent/WorkspaceContent'; 13 | export { WorkspaceHeaderButtons } from './workspace/WorkspaceHeaderButtons/WorkspaceHeaderButtons'; 14 | export { WorkspacePageHeader } from './workspace/WorkspacePageHeader/WorkspacePageHeader'; 15 | export { CssCategoryBar } from './workspace/css/CssCategoryBar/CssCategoryBar'; 16 | export { CssPropsSelectBox } from './workspace/css/CssPropsSelectBox/CssPropsSelectBox'; 17 | export { CssOptionItemList } from './workspace/css/CssOptionItemList/CssOptionItemList'; 18 | export { CssPropsSelectBoxHeader } from './workspace/css/CssPropsSelectBoxHeader/CssPropsSelectBoxHeader'; 19 | export { ImageTagModal } from './workspace/ImageTagModal/ImageTagModal'; 20 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/CoachMark/CoachMark.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import coachMark from './CoachMark'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/coachMark', 7 | component: coachMark, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: '워크스페이스 튜토리얼 코치 마크', 13 | }, 14 | }, 15 | }, 16 | decorators: [ 17 | (Story) => ( 18 |
19 | 20 |
21 | ), 22 | ], 23 | tags: ['autodocs'], 24 | }; 25 | 26 | export default meta; 27 | 28 | type Story = StoryObj; 29 | 30 | export const Default: Story = {}; 31 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/ImageTagModal/ImageTagModal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ImageTagModal } from './ImageTagModal'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/ImageTagModal', 7 | component: ImageTagModal, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/PreviewBox/PreviewBox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { PreviewBox } from './PreviewBox'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/PreviewBox', 7 | component: PreviewBox, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | decorators: [ 12 | (Story) => ( 13 |
14 | 15 |
16 | ), 17 | ], 18 | tags: ['autodocs'], 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | args: { 27 | htmlCode: ` 28 | 29 | 30 |
31 |

Hello, world!

32 |
33 | 34 | `, 35 | cssCode: `.container { 36 | background-color: #f0f0f0; 37 | padding: 1rem; 38 | } 39 | .title { 40 | color: #43a135; 41 | font-size: 2rem; 42 | } 43 | `, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/WorkspaceContent/WorkspaceContent.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceContent } from './WorkspaceContent'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/WorkspaceContent', 7 | component: WorkspaceContent, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | decorators: [ 12 | (Story) => { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | }, 19 | ], 20 | tags: ['autodocs'], 21 | }; 22 | 23 | export default meta; 24 | 25 | type Story = StoryObj; 26 | 27 | export const Default: Story = { 28 | args: { 29 | // propsname: value, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/WorkspaceHeaderButtons/WorkspaceHeaderButtons.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspaceHeaderButtons } from './WorkspaceHeaderButtons'; 4 | 5 | const meta: Meta = { 6 | title: 'entities/workspace/WorkspaceHeaderButtons', 7 | component: WorkspaceHeaderButtons, 8 | parameters: { 9 | layout: 'centered', 10 | docs: { 11 | description: { 12 | component: '워크스페이스 헤더 버튼 모음 컴포넌트', 13 | }, 14 | }, 15 | }, 16 | tags: ['autodocs'], 17 | }; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | export const Default: Story = {}; 24 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/WorkspaceHeaderButtons/WorkspaceHeaderButtons.tsx: -------------------------------------------------------------------------------- 1 | import { CodeExportButton, RedoButton, SaveButton, UndoButton } from '@/entities'; 2 | 3 | import { useCoachMarkStore } from '@/shared/store/useCoachMarkStore'; 4 | 5 | export const WorkspaceHeaderButtons = () => { 6 | const { currentStep } = useCoachMarkStore(); 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/WorkspacePageHeader/WorkspacePageHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { WorkspacePageHeader } from './WorkspacePageHeader'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/WorkspacePageHeader', 7 | component: WorkspacePageHeader, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | // propsname: value, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/WorkspacePageHeader/WorkspacePageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { WorkspaceNameInput } from '@/entities'; 2 | import { Logo } from '@/shared/ui'; 3 | import { WorkspaceHeaderButtons } from '../WorkspaceHeaderButtons/WorkspaceHeaderButtons'; 4 | import { useCoachMarkStore } from '@/shared/store/useCoachMarkStore'; 5 | import Question from '@/shared/assets/question.svg?react'; 6 | 7 | /** 8 | * 9 | * @description 10 | * 워크스페이스 페이지 헤더 컴포넌트 11 | */ 12 | export const WorkspacePageHeader = () => { 13 | const { openCoachMark } = useCoachMarkStore(); 14 | 15 | return ( 16 |
17 |
18 | 19 | 20 |
21 |
22 | 29 | 30 |
31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssCategoryBar/CssCategoryBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssCategoryBar } from './CssCategoryBar'; 4 | 5 | const meta: Meta = { 6 | title: 'widgets/workspace/css/CssCategoryBar', 7 | component: CssCategoryBar, 8 | parameters: { 9 | layout: 'centered', 10 | }, 11 | tags: ['autodocs'], 12 | }; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = { 19 | args: { 20 | // propsname: value, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssCategoryBar/CssCategoryBar.tsx: -------------------------------------------------------------------------------- 1 | import { CssCategoryButton } from '@/entities'; 2 | import { cssCategoryList } from '@/shared/utils'; 3 | 4 | /** 5 | * 6 | * @description 7 | * CSS 카테고리 목록을 보여주고 선택할 수 있는 컴포넌트 8 | */ 9 | export const CssCategoryBar = () => { 10 | return ( 11 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssOptionItemList/CssOptionItemList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssOptionItemList } from './CssOptionItemList'; 4 | import { useCssPropsStore } from '@/shared/store'; 5 | 6 | const meta: Meta = { 7 | title: 'widgets/workspace/css/CssOptionItemList', 8 | component: CssOptionItemList, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = {}; 20 | 21 | export const ClassSelected: Story = { 22 | render: () => { 23 | const { setSelectedCssCategory } = useCssPropsStore(); 24 | const categoryList = [ 25 | '레이아웃', 26 | '박스모델', 27 | '타이포그래피', 28 | '배경', 29 | '테두리', 30 | '간격', 31 | 'flex 속성', 32 | 'grid 속성', 33 | ]; 34 | return ( 35 | <> 36 | 43 | 44 | 45 | ); 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssOptionItemList/CssOptionItemList.tsx: -------------------------------------------------------------------------------- 1 | import { CssOptionItem } from '@/entities'; 2 | import { cssCategoryList } from '@/shared/utils'; 3 | import { useCssPropsStore } from '@/shared/store'; 4 | 5 | /** 6 | * 7 | * @description 8 | * CSS 속성을 설정할 수 있는 컴포넌트의 목록을 보여주는 컴포넌트 9 | */ 10 | export const CssOptionItemList = () => { 11 | const { selectedCssCategory } = useCssPropsStore(); 12 | return ( 13 |
14 | {cssCategoryList 15 | .filter((cssCategory) => cssCategory.category === selectedCssCategory) 16 | .map((cssCategory) => 17 | cssCategory.items.map((cssItem, index) => ( 18 | 19 | )) 20 | )} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssPropsSelectBox/CssPropsSelectBox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssPropsSelectBox } from './CssPropsSelectBox'; 4 | import { useClassBlockStore } from '@/shared/store'; 5 | 6 | const meta: Meta = { 7 | title: 'widgets/workspace/css/CssPropsSelectBox', 8 | component: CssPropsSelectBox, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | tags: ['autodocs'], 13 | }; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | export const Default: Story = { 20 | args: { 21 | // propsname: value, 22 | }, 23 | }; 24 | 25 | export const CanSelectClass: Story = { 26 | render: () => { 27 | const { addClassBlock, classBlockList } = useClassBlockStore(); 28 | 29 | const handleOnBlur = (e: React.FocusEvent) => { 30 | if (e.target.value === '' || classBlockList.includes(e.target.value)) return; 31 | addClassBlock(e.target.value); 32 | }; 33 | 34 | const handleOnKeyDown = (e: React.KeyboardEvent) => { 35 | if (e.key === 'Enter') { 36 | e.currentTarget.blur(); 37 | e.preventDefault(); 38 | } 39 | }; 40 | 41 | return ( 42 |
43 | 50 | 51 |
52 | ); 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssPropsSelectBox/CssPropsSelectBox.tsx: -------------------------------------------------------------------------------- 1 | import { CssCategoryBar, CssOptionItemList, CssPropsSelectBoxHeader } from '@/widgets'; 2 | import { useCoachMarkStore } from '@/shared/store/useCoachMarkStore'; 3 | 4 | /** 5 | * 6 | * @description 7 | * CSS 클래스를 선택하고 CSS 속성을 선택할 수 있는 컴포넌트 8 | */ 9 | export const CssPropsSelectBox = () => { 10 | const { currentStep } = useCoachMarkStore(); 11 | 12 | return ( 13 |
16 | 17 |
18 | 19 | 20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssPropsSelectBoxHeader/CssPropsSelectBoxHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { CssPropsSelectBoxHeader } from './CssPropsSelectBoxHeader'; 4 | import { useClassBlockStore } from '@/shared/store'; 5 | import { useEffect } from 'react'; 6 | 7 | const meta: Meta = { 8 | title: 'widgets/workspace/css/CssPropsSelectBoxHeader', 9 | component: CssPropsSelectBoxHeader, 10 | parameters: { 11 | layout: 'centered', 12 | }, 13 | 14 | tags: ['autodocs'], 15 | }; 16 | 17 | export default meta; 18 | 19 | type Story = StoryObj; 20 | 21 | export const Default: Story = { 22 | args: { 23 | // 필요한 args를 여기에 추가하세요 24 | }, 25 | }; 26 | 27 | export const Resize: Story = { 28 | render: () => ( 29 |
33 | 34 |
35 | ), 36 | }; 37 | 38 | export const ResizeAndCanSelectClass: Story = { 39 | render: () => { 40 | const { addClassBlock } = useClassBlockStore(); 41 | useEffect(() => { 42 | addClassBlock('test1'); 43 | addClassBlock('test2'); 44 | addClassBlock('test3'); 45 | }, []); 46 | return ( 47 |
51 | 52 |
53 | ); 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /apps/client/src/widgets/workspace/css/CssPropsSelectBoxHeader/CssPropsSelectBoxHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Select, TOption } from '@/shared/ui'; 2 | import { addPrefixToCssClassName, removeCssClassNamePrefix } from '@/shared/utils'; 3 | import { useClassBlockStore, useCssPropsStore } from '@/shared/store'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | /** 7 | * 8 | * @description 9 | * CSS 클래스를 선택할 수 있는 헤더 컴포넌트 10 | */ 11 | export const CssPropsSelectBoxHeader = () => { 12 | const { currentCssClassName, setCurrentCssClassName } = useCssPropsStore(); 13 | const { classBlockList } = useClassBlockStore(); 14 | const [cssClassList, setCssClassList] = useState([]); 15 | 16 | useEffect(() => { 17 | setCssClassList(classBlockList); 18 | }, [classBlockList]); 19 | 20 | const selectOptions: TOption[] = [ 21 | { value: '', label: '클래스를 선택해주세요' }, 22 | ...cssClassList.map((cssClass) => ({ 23 | value: cssClass, 24 | label: cssClass, 25 | })), 26 | ]; 27 | 28 | return ( 29 |
30 |

CSS 클래스 속성 편집

31 |