├── .eslintrc.yaml ├── .github └── workflows │ ├── ci.yaml │ ├── deploy.yaml │ └── docker-frontend-image.yml ├── .gitignore ├── .prettierrc.yaml ├── Dockerfile.frontend ├── LICENSE ├── README.md ├── apps ├── backend │ ├── .env.example │ ├── README.md │ ├── docs │ │ ├── index.html │ │ └── styles │ │ │ └── main.css │ ├── nest-cli.json │ ├── package.json │ ├── prisma │ │ └── schema.prisma │ ├── scripts │ │ └── seed │ │ │ ├── data │ │ │ ├── index.ts │ │ │ ├── templates.ts │ │ │ ├── users.ts │ │ │ └── websites.ts │ │ │ ├── seed.ts │ │ │ └── steps │ │ │ ├── create-templates.ts │ │ │ ├── create-users.ts │ │ │ ├── create-websites.ts │ │ │ └── index.ts │ ├── src │ │ ├── app │ │ │ ├── app.controller.ts │ │ │ ├── app.module.ts │ │ │ ├── guards │ │ │ │ ├── auth.guard.ts │ │ │ │ ├── host.guard.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── middlewares │ │ │ │ ├── index.ts │ │ │ │ └── logger.ts │ │ ├── config │ │ │ ├── app.config.ts │ │ │ ├── env.config.ts │ │ │ ├── logger.config.ts │ │ │ └── static.config.ts │ │ ├── features │ │ │ ├── auth │ │ │ │ ├── auth.controller.ts │ │ │ │ ├── auth.module.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── auth.type.ts │ │ │ │ ├── dtos │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── sign-in.dto.ts │ │ │ │ │ └── sign-up.dto.ts │ │ │ │ └── index.ts │ │ │ ├── template │ │ │ │ ├── dtos │ │ │ │ │ ├── create.dto.ts │ │ │ │ │ ├── get-by-id.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update.dto.ts │ │ │ │ ├── index.ts │ │ │ │ ├── template.controller.ts │ │ │ │ ├── template.module.ts │ │ │ │ ├── template.service.ts │ │ │ │ └── template.type.ts │ │ │ └── user │ │ │ │ ├── dtos │ │ │ │ ├── create.dto.ts │ │ │ │ ├── delete.dto.ts │ │ │ │ ├── get-by-id.dto.ts │ │ │ │ ├── index.ts │ │ │ │ └── update.dto.ts │ │ │ │ ├── helpers │ │ │ │ ├── index.ts │ │ │ │ ├── remove-sensitives.ts │ │ │ │ └── user-to-profile.ts │ │ │ │ ├── index.ts │ │ │ │ ├── user.controller.ts │ │ │ │ ├── user.module.ts │ │ │ │ ├── user.service.ts │ │ │ │ └── user.type.ts │ │ ├── filters │ │ │ ├── all-exceptions.filter.ts │ │ │ └── index.ts │ │ ├── global.d.ts │ │ ├── interceptors │ │ │ ├── index.ts │ │ │ ├── response.interceptor.ts │ │ │ └── timeout.interceptor.ts │ │ ├── main.ts │ │ ├── pipes │ │ │ ├── index.ts │ │ │ └── validation.pipe.ts │ │ ├── plugins │ │ │ └── prisma-client │ │ │ │ └── index.ts │ │ ├── shared │ │ │ ├── constants │ │ │ │ ├── errors.ts │ │ │ │ ├── index.ts │ │ │ │ ├── timeout.ts │ │ │ │ └── token-key.ts │ │ │ ├── crypt │ │ │ │ ├── crypt.module.ts │ │ │ │ ├── crypt.service.ts │ │ │ │ └── index.ts │ │ │ ├── database │ │ │ │ ├── database.service.ts │ │ │ │ ├── database.types.ts │ │ │ │ └── index.ts │ │ │ ├── decorators │ │ │ │ ├── authorize.decorator.ts │ │ │ │ ├── index.ts │ │ │ │ └── skip-auth.decorator.ts │ │ │ ├── env │ │ │ │ ├── env.module.ts │ │ │ │ ├── env.service.ts │ │ │ │ └── index.ts │ │ │ ├── guards │ │ │ │ ├── index.ts │ │ │ │ └── roles.ts │ │ │ └── token │ │ │ │ ├── index.ts │ │ │ │ ├── token.module.ts │ │ │ │ ├── token.service.ts │ │ │ │ └── token.type.ts │ │ ├── types │ │ │ ├── index.ts │ │ │ └── response.type.ts │ │ └── utils │ │ │ ├── colored-http-logs.ts │ │ │ ├── handle-with-internal-error.ts │ │ │ ├── index.ts │ │ │ └── request-from-context.ts │ └── tsconfig.json └── frontend │ ├── .env.example │ ├── README.md │ ├── default.conf │ ├── index.html │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ └── vite.svg │ ├── scripts │ ├── load-icons.sh │ └── modules │ │ └── loadIcons.ts │ ├── src │ ├── App.tsx │ ├── assets │ │ └── icons │ │ │ └── box.png │ ├── components │ │ ├── Common │ │ │ ├── Buttons │ │ │ │ ├── EditorButton.tsx │ │ │ │ └── index.ts │ │ │ ├── ContextMenu │ │ │ │ ├── ContextMenu.tsx │ │ │ │ ├── ContextMenuItem.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── ComponentsList │ │ │ ├── ComponentsList.tsx │ │ │ ├── Group.tsx │ │ │ ├── GroupItem.tsx │ │ │ ├── GroupsList.tsx │ │ │ └── index.ts │ │ ├── Decorators │ │ │ ├── Draggable.tsx │ │ │ ├── Droppable.tsx │ │ │ ├── DynamicComponent.tsx │ │ │ ├── Icon.tsx │ │ │ ├── WithContextMenu.tsx │ │ │ └── index.ts │ │ ├── Icons │ │ │ ├── AddCircleLine.tsx │ │ │ ├── ArrowSmallDown.tsx │ │ │ ├── ArrowSmallUp.tsx │ │ │ ├── Bars.tsx │ │ │ ├── ChevronLeft.tsx │ │ │ ├── CrossMark.tsx │ │ │ ├── Cubes.tsx │ │ │ ├── DesktopScreen.tsx │ │ │ ├── GripVertical.tsx │ │ │ ├── InformationCircle.tsx │ │ │ ├── Minus.tsx │ │ │ ├── MobileScreen.tsx │ │ │ ├── Option.tsx │ │ │ ├── RefreshArrows.tsx │ │ │ ├── Save.tsx │ │ │ ├── Search.tsx │ │ │ ├── Stack.tsx │ │ │ └── User.tsx │ │ ├── Preview │ │ │ ├── Preview.tsx │ │ │ ├── PreviewComponentWrapper.tsx │ │ │ ├── PreviewDroppable.tsx │ │ │ └── index.ts │ │ ├── PropertyComponents │ │ │ ├── Property │ │ │ │ ├── ColorProperty.tsx │ │ │ │ ├── GridTemplateProperty.tsx │ │ │ │ ├── NumberProperty.tsx │ │ │ │ ├── RangeProperty.tsx │ │ │ │ ├── TextProperty.tsx │ │ │ │ └── index.ts │ │ │ ├── PropertyWrapper.tsx │ │ │ ├── components-map.ts │ │ │ └── index.ts │ │ ├── Providers │ │ │ ├── MittProvider.tsx │ │ │ └── index.ts │ │ ├── ReOrganizer │ │ │ ├── ReOrganizer.tsx │ │ │ ├── ReOrganizerItem.tsx │ │ │ └── index.ts │ │ └── SideBar │ │ │ ├── ActiveTab.tsx │ │ │ ├── BaseSideBar.tsx │ │ │ ├── DisplayedProperties.tsx │ │ │ ├── Menu.tsx │ │ │ ├── ScreenChanger.tsx │ │ │ ├── SearchBar.tsx │ │ │ ├── SideBarBody.tsx │ │ │ ├── SideBarLeft.tsx │ │ │ ├── SideBarRight.tsx │ │ │ ├── SideBarSection.tsx │ │ │ ├── SideBarTabTitle.tsx │ │ │ ├── TabChooser.tsx │ │ │ └── index.ts │ ├── contexts │ │ └── mitt.ts │ ├── hooks │ │ ├── index.ts │ │ ├── queries │ │ │ ├── index.ts │ │ │ ├── useLogin.ts │ │ │ ├── useUser.ts │ │ │ └── utils │ │ │ │ ├── index.ts │ │ │ │ ├── make-request.ts │ │ │ │ └── query-factory.ts │ │ ├── useContextMenu.ts │ │ └── useMitt.ts │ ├── main.css │ ├── main.tsx │ ├── plugins │ │ └── mitt │ │ │ └── index.ts │ ├── reportWebVitals.ts │ ├── router │ │ ├── router.ts │ │ └── routes.tsx │ ├── store │ │ ├── activeComponent │ │ │ └── activeComponentSlice.ts │ │ ├── previewTree │ │ │ └── previewTreeSlice.ts │ │ └── store.ts │ ├── types │ │ ├── active-component.type.ts │ │ ├── context-menu.type.ts │ │ ├── events.type.ts │ │ ├── icons.type.ts │ │ ├── index.ts │ │ ├── preview.type.ts │ │ ├── property.type.ts │ │ ├── tooltip.type.ts │ │ └── tree.type.ts │ ├── utils │ │ ├── index.ts │ │ └── remove-non-serializable.ts │ ├── views │ │ ├── editor │ │ │ ├── Editor.tsx │ │ │ ├── EditorPage.tsx │ │ │ ├── Preview.tsx │ │ │ └── index.ts │ │ └── home │ │ │ └── HomePage.tsx │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── vite.config.ts ├── package.json ├── packages ├── functions │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── others │ │ │ ├── arrayToGridFlowTemplate.ts │ │ │ ├── file2base64.ts │ │ │ ├── findCombinations.ts │ │ │ ├── gridFlowTemplateToArray.ts │ │ │ ├── index.ts │ │ │ └── innerContentOfHtmlDiv.ts │ │ ├── parsers │ │ │ ├── index.ts │ │ │ └── specsValuesParser.ts │ │ └── stringutils │ │ │ ├── capitalize.ts │ │ │ ├── index.ts │ │ │ ├── kebabToPascal.ts │ │ │ ├── kebabToSnake.ts │ │ │ ├── pascalToKebab.ts │ │ │ ├── pascalToSnake.ts │ │ │ └── pascalToSpaced.ts │ └── tsconfig.json ├── types │ ├── README.md │ ├── index.ts │ ├── package.json │ └── tsconfig.json └── ui │ ├── .storybook │ ├── main.ts │ ├── manager.ts │ └── preview.ts │ ├── README.md │ ├── index.html │ ├── libs │ ├── index.ts │ └── prettier.ts │ ├── package.json │ ├── postcss.config.cjs │ ├── scripts │ ├── create-component.sh │ ├── expose-components.sh │ └── modules │ │ ├── createComponent.ts │ │ ├── exposeComponents.ts │ │ └── rewrite-styles.ts │ ├── src │ ├── App.tsx │ ├── components │ │ ├── exposed │ │ │ ├── Buttons │ │ │ │ ├── Button │ │ │ │ │ ├── Button.component.tsx │ │ │ │ │ ├── Button.module.css │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ ├── Button.types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── Button2 │ │ │ │ │ ├── Button2.component.tsx │ │ │ │ │ ├── Button2.module.css │ │ │ │ │ ├── Button2.stories.tsx │ │ │ │ │ ├── Button2.types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── Button3 │ │ │ │ │ ├── Button3.component.tsx │ │ │ │ │ ├── Button3.module.css │ │ │ │ │ ├── Button3.stories.tsx │ │ │ │ │ ├── Button3.types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── Button5 │ │ │ │ │ ├── Button5.component.tsx │ │ │ │ │ ├── Button5.module.css │ │ │ │ │ ├── Button5.stories.tsx │ │ │ │ │ ├── Button5.types.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ └── Layouts │ │ │ │ ├── ColumnLayout │ │ │ │ ├── ColumnLayout.component.tsx │ │ │ │ ├── ColumnLayout.module.css │ │ │ │ ├── ColumnLayout.stories.tsx │ │ │ │ ├── ColumnLayout.types.ts │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ ├── icons │ │ │ ├── UiButtonPlay.tsx │ │ │ ├── UiDefault.tsx │ │ │ ├── UiEject.tsx │ │ │ ├── UiTableColumns.tsx │ │ │ └── UiToggleOff.tsx │ │ └── index.ts │ ├── index.ts │ ├── main.css │ ├── main.tsx │ ├── utils │ │ ├── argtypes-controls-parser.ts │ │ └── index.ts │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json ├── tsconfig.node.json └── turbo.json /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - eslint:recommended 4 | - plugin:@typescript-eslint/recommended 5 | - plugin:react/recommended 6 | - plugin:prettier/recommended 7 | parser: '@typescript-eslint/parser' 8 | parserOptions: 9 | ecmaVersion: 'latest' 10 | ecmaFeatures: 11 | jsx: true 12 | sourceType: module 13 | plugins: 14 | - 'react' 15 | - '@typescript-eslint' 16 | - 'prettier' 17 | rules: 18 | no-shadow: 'off' 19 | no-use-before-define: 'off' 20 | comma-dangle: 21 | - 'error' 22 | - 'only-multiline' 23 | 'react/prop-types': 'off' 24 | 'react/react-in-jsx-scope': 'off' 25 | 'react/jsx-pascal-case': 'error' 26 | 'react/no-deprecated': 'error' 27 | '@typescript-eslint/no-shadow': 'error' 28 | '@typescript-eslint/no-use-before-define': 'off' 29 | '@typescript-eslint/no-namespace': 'off' 30 | '@typescript-eslint/no-empty-interface': 'off' 31 | ignorePatterns: 32 | - '**/*/node_modules' 33 | - '**/*/dist' 34 | - '**/*/build' 35 | settings: 36 | 'react': 37 | 'version': 'detect' 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: pull_request 3 | jobs: 4 | ci: 5 | name: Run CI checks 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [16] 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v2 15 | id: pnpm-install 16 | with: 17 | run_install: false 18 | 19 | - name: Get pnpm store directory 20 | id: pnpm-cache 21 | shell: bash 22 | run: | 23 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 24 | 25 | - name: Setup pnpm cache 26 | uses: actions/cache@v3 27 | with: 28 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 29 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pnpm-store- 32 | 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | cache: 'pnpm' 38 | 39 | - name: Install dependencies 40 | run: pnpm install 41 | 42 | - name: Run ESLint 43 | run: pnpm lint 44 | 45 | - name: Run Prettier 46 | run: pnpm format 47 | 48 | - name: Run Jest 49 | run: pnpm test 50 | 51 | - name: Check build 52 | run: pnpm build 53 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_dispatch: # click the button on GitHub repo! 4 | jobs: 5 | sync_latest_from_upstream: 6 | runs-on: ubuntu-latest 7 | name: Sync latest commits from upstream repo 8 | steps: 9 | - name: Checkout target repo 10 | uses: actions/checkout@v3 11 | - name: Rebase 12 | run: | 13 | git config --global user.name "${GITHUB_ACTOR}" 14 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" 15 | git remote set-url origin "https://${GITHUB_ACTOR}:${{ secrets.DEPLOY_PAT }}@github.com/${{ github.repository }}.git" 16 | git remote add upstream "https://github.com/${{ vars.UPSTREAM_USER }}/${{ vars.UPSTREAM_REPO_NAME }}.git" 17 | git remote -v 18 | git fetch upstream main 19 | git reset --hard "upstream/main" 20 | 21 | if [ $? -eq 0 ]; then 22 | git push --force origin main 23 | fi 24 | -------------------------------------------------------------------------------- /.github/workflows/docker-frontend-image.yml: -------------------------------------------------------------------------------- 1 | name: Deployment on frontend stagging 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v1 20 | 21 | - name: Login to GHCR 22 | uses: docker/login-action@v1 23 | with: 24 | registry: ghcr.io 25 | username: epoundor 26 | password: ${{ secrets.GHCR_TOKEN}} 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@v2 30 | with: 31 | context: . 32 | push: true 33 | file: Dockerfile.frontend 34 | tags: ghcr.io/epoundor/react-site-editor:stagging 35 | # no-cache: true 36 | 37 | - name: Deploy on Render 38 | uses: johnbeynon/render-deploy-action@v0.0.8 39 | with: 40 | service-id: ${{ secrets.RENDER_SERVICE_ID }} 41 | api-key: ${{ secrets.RENDER_API_KEY }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | **/*/dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode 17 | !.vscode/extensions.json 18 | .idea 19 | **/*/.DS_Store 20 | *.suo 21 | *.ntvs*gcam 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | **/*/package-lock.json 27 | .env 28 | vite.config.ts 29 | .DS_Store 30 | 31 | .turbo 32 | .eslintcache 33 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | bracketSameLine: true 2 | printWidth: 100 3 | proseWrap: 'always' 4 | semi: true 5 | singleQuote: true 6 | tabWidth: 4 7 | trailingComma: 'none' 8 | useTabs: false 9 | overrides: 10 | - files: 11 | - '*.html' 12 | options: 13 | singleQuote: false 14 | -------------------------------------------------------------------------------- /Dockerfile.frontend: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:18-alpine as build-stage 3 | RUN npm install -g pnpm 4 | WORKDIR /app 5 | COPY package*.json ./ 6 | RUN pnpm install 7 | COPY . . 8 | # RUN cd apps/frontend 9 | RUN pnpm frontend install 10 | RUN pnpm frontend:build 11 | RUN ls -l 12 | # production stage 13 | FROM nginx:stable-alpine as production-stage 14 | COPY apps/frontend/default.conf /etc/nginx/conf.d/default.conf 15 | COPY --from=build-stage app/apps/frontend/dist /usr/share/nginx/html 16 | EXPOSE 80 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Frelya 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/backend/.env.example: -------------------------------------------------------------------------------- 1 | # Server 2 | NODE_ENV= 3 | PORT= 4 | CLIENT_URL= 5 | 6 | # Database 7 | DATABASE_USER= 8 | DATABASE_USER_PASSWORD= 9 | DATABASE_NAME= 10 | DATABASE_URL=mongodb+srv://${DATABASE_USER}:${DATABASE_USER_PASSWORD}@basecluster.mpddb5k.mongodb.net/${DATABASE_NAME} 11 | 12 | # JWT 13 | JWT_SECRET= 14 | JWT_EXPIRES_IN= 15 | 16 | # Crypt 17 | CRYPT_SALT_ROUNDS= 18 | -------------------------------------------------------------------------------- /apps/backend/README.md: -------------------------------------------------------------------------------- 1 | # React-Site-Editor - Backend 2 | 3 | ## Description 4 | -------------------------------------------------------------------------------- /apps/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": ".", 5 | "entryFile": "src/main", 6 | "compilerOptions": { 7 | "assets": ["docs/**"], 8 | "deleteOutDir": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-site-editor/backend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "React Site Editor website backend", 6 | "scripts": { 7 | "build": "pnpm db:generate && nest build", 8 | "start": "node src/main", 9 | "dev": "nest start --watch", 10 | "db:generate": "prisma generate", 11 | "db:push": "prisma db push", 12 | "db:seed": "prisma db seed" 13 | }, 14 | "dependencies": { 15 | "@nestjs/common": "^10.0.0", 16 | "@nestjs/config": "^3.0.0", 17 | "@nestjs/core": "^10.0.0", 18 | "@nestjs/jwt": "^10.1.0", 19 | "@nestjs/platform-express": "^10.0.0", 20 | "@nestjs/serve-static": "^4.0.0", 21 | "@prisma/client": "^4.16.0", 22 | "@react-site-editor/types": "0.0.1", 23 | "bcrypt": "^5.1.0", 24 | "bson": "^5.4.0", 25 | "class-transformer": "^0.5.1", 26 | "class-validator": "^0.14.0", 27 | "cookie-parser": "^1.4.6", 28 | "helmet": "^7.0.0", 29 | "nest-winston": "^1.9.2", 30 | "prisma": "^4.16.0", 31 | "reflect-metadata": "^0.1.13", 32 | "response-time": "^2.3.2", 33 | "rxjs": "^7.8.1", 34 | "winston": "^3.9.0", 35 | "zod": "^3.21.4" 36 | }, 37 | "devDependencies": { 38 | "@nestjs/cli": "^10.0.0", 39 | "@nestjs/schematics": "^10.0.0", 40 | "@types/bcrypt": "^5.0.0", 41 | "@types/cookie-parser": "^1.4.4", 42 | "@types/express": "^4.17.17", 43 | "@types/module-alias": "^2.0.1", 44 | "@types/node": "^20.3.1", 45 | "@types/response-time": "^2.3.5", 46 | "prisma-docs-generator": "^0.8.0", 47 | "source-map-support": "^0.5.21", 48 | "ts-loader": "^9.4.3", 49 | "ts-node": "^10.9.1", 50 | "typescript": "^4.9.3" 51 | }, 52 | "prisma": { 53 | "seed": "ts-node ./scripts/seed/seed.ts" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/data/index.ts: -------------------------------------------------------------------------------- 1 | export { default as usersData } from './users'; 2 | 3 | export { default as templatesData } from './templates'; 4 | 5 | export { default as websitesData } from './websites'; 6 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/data/templates.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, MediaType } from '.prisma/client'; 2 | 3 | const templatesData: Prisma.XOR[] = 4 | [ 5 | { 6 | name: 'Sample Template', 7 | tree: '{"pages":[{"title":"Home","url":"/","content":"Welcome to my template!"}]}', 8 | createdAt: new Date(), 9 | likes: 10, 10 | medias: [], 11 | authorId: '' 12 | }, 13 | { 14 | name: 'Sample Template 2', 15 | tree: '{"pages":[{"title":"Contact","url":"/contact","content":"Welcome to my second template!"}]}', 16 | createdAt: new Date(), 17 | medias: [ 18 | { 19 | name: 'Sample Video', 20 | type: MediaType.Video, 21 | uri: '/path/to/image.jpg', 22 | createdAt: new Date() 23 | } 24 | ], 25 | authorId: '' 26 | } 27 | ]; 28 | 29 | export default templatesData; 30 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/data/users.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, Role, Membership } from '.prisma/client'; 2 | 3 | const usersData: Prisma.XOR[] = [ 4 | { 5 | role: Role.Admin, 6 | username: 'admin', 7 | email: 'admin-password123@example.com', 8 | password: '$2b$10$Rge/ieVfL4nv1//3anhHEemd66kn1sA9PxOaLc7DWGGVxoMllBtey', 9 | firstName: 'John', 10 | lastName: 'Doe', 11 | membership: Membership.Premium, 12 | lastLogin: null, 13 | createdAt: new Date() 14 | }, 15 | { 16 | role: Role.User, 17 | username: 'johndoe', 18 | email: 'johndoe-pwd123@example.com', 19 | password: '$2b$10$r8LyyLQNOFzjY1jZ/M0Bp.K6tpwNj.CqBDqaIZsJHvFqeb8U7wl32', 20 | firstName: null, 21 | lastName: 'Doe', 22 | membership: Membership.Free, 23 | lastLogin: new Date(), 24 | createdAt: new Date() 25 | } 26 | ]; 27 | 28 | export default usersData; 29 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/data/websites.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, MediaType } from '.prisma/client'; 2 | 3 | const websitesData: Prisma.XOR[] = [ 4 | { 5 | url: 'https://mywebsite.com', 6 | tree: '{"pages":[{"title":"Home","url":"/","content":"Welcome to my website!"}]}', 7 | createdAt: new Date(), 8 | authorId: '', 9 | templateId: null, 10 | medias: [ 11 | { 12 | name: 'Sample Image', 13 | type: MediaType.Image, 14 | uri: '/path/to/image.jpg', 15 | createdAt: new Date() 16 | } 17 | ] 18 | }, 19 | { 20 | title: 'My Website', 21 | url: 'https://mysecondwebsite.com', 22 | tree: '{"pages":[{"title":"Home","url":"/","content":"Welcome to my second website!"}]}', 23 | createdAt: new Date(), 24 | authorId: '', 25 | templateId: '', 26 | medias: [] 27 | } 28 | ]; 29 | 30 | export default websitesData; 31 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '.prisma/client'; 2 | 3 | import { isDevelopment } from '../../src/config/env.config'; 4 | 5 | import { createUsers, createTemplates, createWebsites } from './steps'; 6 | 7 | async function seed(client: PrismaClient) { 8 | const usersIds = await createUsers(client); 9 | const templatesIds = await createTemplates(client, usersIds); 10 | await createWebsites(client, usersIds, templatesIds); 11 | } 12 | 13 | if (isDevelopment()) { 14 | console.log('\n🌱 Seeding database...'); 15 | 16 | const prisma = new PrismaClient(); 17 | 18 | seed(prisma) 19 | .then(() => { 20 | console.log('\n🌱 Seed completed successfully !'); 21 | }) 22 | .catch(async (error) => { 23 | console.error(error); 24 | }) 25 | .finally(async () => { 26 | await prisma.$disconnect(); 27 | }); 28 | } else { 29 | console.error( 30 | '\n🚫 Seed is only available in development mode !\n' + 31 | ' Change the NODE_ENV variable to development and try again.' 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/steps/create-templates.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Template, User } from '.prisma/client'; 2 | 3 | import { templatesData } from '../data'; 4 | 5 | export async function createTemplates( 6 | client: PrismaClient, 7 | authorIds: User['id'][] 8 | ): Promise { 9 | await client.template.deleteMany({}); 10 | 11 | templatesData[0].authorId = authorIds[0]; 12 | templatesData[1].authorId = authorIds[1]; 13 | 14 | return Promise.all( 15 | templatesData.map(async (templateData) => { 16 | const { id } = await client.template.upsert({ 17 | where: { 18 | name: templateData.name 19 | }, 20 | update: templateData, 21 | create: templateData, 22 | select: { 23 | id: true 24 | } 25 | }); 26 | 27 | return id; 28 | }) 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/steps/create-users.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, User } from '.prisma/client'; 2 | 3 | import { usersData } from '../data'; 4 | 5 | export async function createUsers(client: PrismaClient): Promise { 6 | await client.user.deleteMany({}); 7 | 8 | return Promise.all( 9 | usersData.map(async (userData) => { 10 | const { id } = await client.user.upsert({ 11 | where: { 12 | email: userData.email 13 | }, 14 | update: userData, 15 | create: userData, 16 | select: { 17 | id: true 18 | } 19 | }); 20 | 21 | return id; 22 | }) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/steps/create-websites.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Website, User, Template } from '.prisma/client'; 2 | 3 | import { websitesData } from '../data'; 4 | 5 | export async function createWebsites( 6 | client: PrismaClient, 7 | authorIds: User['id'][], 8 | templatesIds: Template['id'][] 9 | ): Promise { 10 | await client.website.deleteMany({}); 11 | 12 | websitesData[0].authorId = authorIds[0]; 13 | 14 | websitesData[1].authorId = authorIds[1]; 15 | websitesData[1].templateId = templatesIds[1]; 16 | 17 | return Promise.all( 18 | websitesData.map(async (websiteData) => { 19 | const { id } = await client.website.upsert({ 20 | where: { 21 | url: websiteData.url 22 | }, 23 | update: websiteData, 24 | create: websiteData, 25 | select: { 26 | id: true 27 | } 28 | }); 29 | 30 | return id; 31 | }) 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/backend/scripts/seed/steps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-users'; 2 | 3 | export * from './create-templates'; 4 | 5 | export * from './create-websites'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Redirect } from '@nestjs/common'; 2 | 3 | import { SkipAuth } from '@shared/decorators'; 4 | 5 | @Controller() 6 | export class AppController { 7 | @SkipAuth() 8 | @Get() 9 | @Redirect('/docs') 10 | getApp(): string { 11 | return 'Redirecting to the API documentation...'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER, APP_PIPE } from '@nestjs/core'; 2 | import { Module, Logger, NestModule, MiddlewareConsumer } from '@nestjs/common'; 3 | import { ServeStaticModule } from '@nestjs/serve-static'; 4 | import helmet from 'helmet'; 5 | import cookieParser from 'cookie-parser'; 6 | import responseTime from 'response-time'; 7 | 8 | import { envConfigOptions } from '@config/env.config'; 9 | import { serveStaticOptions } from '@config/static.config'; 10 | import { EnvModule, EnvService } from '@shared/env'; 11 | import { AuthModule } from '@features/auth'; 12 | import { UserModule } from '@features/user'; 13 | import { TemplateModule } from '@features/template'; 14 | import { TokenModule } from '@shared/token'; 15 | import { AllExceptionsFilter } from '@/filters'; 16 | import { ResponseInterceptor } from '@/interceptors'; 17 | import { TimeoutInterceptor } from '@/interceptors'; 18 | import { ValidationPipe } from '@/pipes'; 19 | 20 | import { HostGuard, AuthGuard } from './guards'; 21 | import { LoggerMiddleware } from './middlewares'; 22 | import { AppController } from './app.controller'; 23 | 24 | @Module({ 25 | imports: [ 26 | EnvModule.forRoot(envConfigOptions), 27 | ServeStaticModule.forRoot(serveStaticOptions), 28 | TokenModule, 29 | AuthModule, 30 | UserModule, 31 | TemplateModule 32 | ], 33 | providers: [ 34 | Logger, 35 | EnvService, 36 | { 37 | provide: APP_GUARD, 38 | useClass: HostGuard 39 | }, 40 | { 41 | provide: APP_GUARD, 42 | useClass: AuthGuard 43 | }, 44 | { 45 | provide: APP_INTERCEPTOR, 46 | useClass: TimeoutInterceptor 47 | }, 48 | { 49 | provide: APP_INTERCEPTOR, 50 | useClass: ResponseInterceptor 51 | }, 52 | { 53 | provide: APP_FILTER, 54 | useClass: AllExceptionsFilter 55 | }, 56 | { 57 | provide: APP_PIPE, 58 | useClass: ValidationPipe 59 | } 60 | ], 61 | controllers: [AppController] 62 | }) 63 | export class AppModule implements NestModule { 64 | configure(consumer: MiddlewareConsumer): void { 65 | consumer 66 | .apply( 67 | helmet(), 68 | cookieParser(), 69 | responseTime({ 70 | digits: 0 71 | }), 72 | LoggerMiddleware 73 | ) 74 | .forRoutes('*'); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /apps/backend/src/app/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import type { Request } from 'express'; 4 | 5 | import { TokenService, Tokens } from '@shared/token'; 6 | import { ERRORS, TOKEN_KEY } from '@shared/constants'; 7 | import { SKIP_AUTH_KEY } from '@shared/decorators'; 8 | import { extractRequestFromContext } from '@/utils'; 9 | 10 | @Injectable() 11 | export class AuthGuard implements CanActivate { 12 | constructor( 13 | private readonly tokenService: TokenService, 14 | private readonly reflector: Reflector 15 | ) {} 16 | 17 | private static extractTokenFromCookies(request: Request): Tokens.AccessToken | undefined { 18 | return request.cookies[TOKEN_KEY]; 19 | } 20 | 21 | async canActivate(context: ExecutionContext): Promise { 22 | const isSkipAuth = this.reflector.getAllAndOverride(SKIP_AUTH_KEY, [ 23 | context.getHandler(), 24 | context.getClass() 25 | ]); 26 | 27 | if (isSkipAuth) { 28 | return true; 29 | } 30 | 31 | const request = extractRequestFromContext(context); 32 | const token = AuthGuard.extractTokenFromCookies(request); 33 | 34 | if (!token) { 35 | throw new UnauthorizedException(ERRORS.TOKEN_REQUIRED); 36 | } 37 | 38 | request.user = await this.tokenService.verifyAccessToken(token); 39 | 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/backend/src/app/guards/host.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | 3 | import { EnvService } from '@shared/env'; 4 | import { extractRequestFromContext } from '@/utils'; 5 | 6 | @Injectable() 7 | export class HostGuard implements CanActivate { 8 | constructor(private readonly envService: EnvService) {} 9 | 10 | canActivate(context: ExecutionContext): boolean { 11 | const request = extractRequestFromContext(context); 12 | const host = request.get('host'); 13 | 14 | if (this.envService.isProduction) { 15 | return host === this.envService.get('CLIENT_URL'); 16 | } 17 | 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/backend/src/app/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.guard'; 2 | 3 | export * from './host.guard'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export { AppModule } from './app.module'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/app/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/app/middlewares/logger.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import type { Request, Response, NextFunction } from 'express'; 3 | 4 | import { colorMethod, colorStatusCode, colorNotImportant } from '@/utils'; 5 | 6 | @Injectable() 7 | export class LoggerMiddleware implements NestMiddleware { 8 | private readonly logger: Logger = new Logger(Logger.name); 9 | 10 | use(request: Request, response: Response, next: NextFunction): void { 11 | response.on('finish', () => { 12 | const { method, originalUrl, httpVersion } = request; 13 | const { statusCode } = response; 14 | const responseTime = response.get('X-Response-Time'); 15 | 16 | this.logger.log( 17 | `${colorMethod(method)} ` + 18 | `${originalUrl} ` + 19 | `${colorStatusCode(statusCode)} ` + 20 | '- ' + 21 | `${colorNotImportant(responseTime)} ` + 22 | `${colorNotImportant(`(HTTP/${httpVersion})`)}` 23 | ); 24 | }); 25 | 26 | next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/backend/src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import type { NestApplicationOptions } from '@nestjs/common'; 2 | import { WinstonModule } from 'nest-winston'; 3 | 4 | import loggerOptions from '@config/logger.config'; 5 | 6 | const customLogger = WinstonModule.createLogger(loggerOptions); 7 | 8 | const appOptions: NestApplicationOptions = { 9 | bodyParser: true, 10 | bufferLogs: true, 11 | logger: customLogger 12 | }; 13 | 14 | export default appOptions; 15 | -------------------------------------------------------------------------------- /apps/backend/src/config/env.config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigModuleOptions } from '@nestjs/config'; 2 | import { z } from 'zod'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | type AnyObject = { [key: string]: unknown }; 8 | 9 | const envVariablesSchema = z.object({ 10 | NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), 11 | PORT: z.string().default('8000'), 12 | CLIENT_URL: z.string(), 13 | DATABASE_USER: z.string(), 14 | DATABASE_USER_PASSWORD: z.string(), 15 | DATABASE_NAME: z.enum(['rse_dev', 'rse_prod', 'rse_test']).default('rse_dev'), 16 | DATABASE_URL: z.string(), 17 | JWT_SECRET: z.string(), 18 | JWT_EXPIRES_IN: z.string(), 19 | CRYPT_SALT_ROUNDS: z.string() 20 | }); 21 | 22 | const envVariables = envVariablesSchema.parse(process.env); 23 | 24 | function isDevelopment(): boolean { 25 | return envVariables.NODE_ENV === 'development'; 26 | } 27 | 28 | function isProduction(): boolean { 29 | return envVariables.NODE_ENV === 'production'; 30 | } 31 | 32 | function isTest(): boolean { 33 | return envVariables.NODE_ENV === 'test'; 34 | } 35 | 36 | function checkUnsetVariables(variables: AnyObject): void { 37 | Object.entries(variables).forEach(([key, value]) => { 38 | if (value === undefined || value === '') { 39 | throw new Error(`Environment variable ${key} is not set`); 40 | } 41 | }); 42 | } 43 | 44 | const environment = () => { 45 | checkUnsetVariables(envVariables as unknown as AnyObject); 46 | return envVariables; 47 | }; 48 | 49 | const envConfigOptions: ConfigModuleOptions = { 50 | cache: true, 51 | isGlobal: true, 52 | load: [environment] 53 | }; 54 | 55 | export { isDevelopment, isProduction, isTest, environment, envVariablesSchema, envConfigOptions }; 56 | -------------------------------------------------------------------------------- /apps/backend/src/config/logger.config.ts: -------------------------------------------------------------------------------- 1 | import type { WinstonModuleOptions } from 'nest-winston'; 2 | import winston from 'winston'; 3 | 4 | import { isProduction } from '@config/env.config'; 5 | 6 | type LogInfo = winston.Logform.TransformableInfo; 7 | 8 | const formattedInfoLevel = (info: LogInfo): LogInfo => { 9 | const maxLevelLength = 5; 10 | 11 | return { 12 | ...info, 13 | level: info.level.toUpperCase().padEnd(maxLevelLength + 1, ' ') 14 | }; 15 | }; 16 | 17 | const loggerOptions: WinstonModuleOptions = { 18 | level: isProduction() ? 'info' : 'debug', 19 | 20 | transports: [new winston.transports.Console()], 21 | 22 | format: winston.format.combine( 23 | winston.format(formattedInfoLevel)(), 24 | 25 | winston.format.colorize({ 26 | colors: { 27 | info: 'blue', 28 | debug: 'magenta' 29 | } 30 | }), 31 | 32 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 33 | 34 | winston.format.printf((info: LogInfo) => { 35 | if (info.stack) { 36 | return `[${info.timestamp}] ${info.level}: ${info.message} - ${info.stack}`; 37 | } 38 | return `[${info.timestamp}] ${info.level}: ${info.message}`; 39 | }) 40 | ), 41 | 42 | exitOnError: false 43 | }; 44 | 45 | export default loggerOptions; 46 | -------------------------------------------------------------------------------- /apps/backend/src/config/static.config.ts: -------------------------------------------------------------------------------- 1 | import type { ServeStaticModuleOptions } from '@nestjs/serve-static'; 2 | import * as path from 'path'; 3 | 4 | const serveStaticOptions: ServeStaticModuleOptions = { 5 | rootPath: path.join(__dirname, '..', '..', 'docs'), 6 | serveRoot: '/docs' 7 | }; 8 | 9 | export { serveStaticOptions }; 10 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, HttpCode, Res, HttpStatus } from '@nestjs/common'; 2 | import type { Response } from 'express'; 3 | 4 | import { TOKEN_KEY } from '@shared/constants'; 5 | import { SkipAuth } from '@shared/decorators'; 6 | import { EnvService } from '@shared/env'; 7 | 8 | import { AuthService } from './auth.service'; 9 | import { Auth } from './auth.type'; 10 | import { SignInDto, SignUpDto } from './dtos'; 11 | 12 | @Controller('auth') 13 | export class AuthController { 14 | constructor( 15 | private readonly envService: EnvService, 16 | private readonly authService: AuthService 17 | ) {} 18 | 19 | @SkipAuth() 20 | @HttpCode(HttpStatus.OK) 21 | @Post('login') 22 | async signIn( 23 | @Body() signInDto: SignInDto, 24 | @Res({ passthrough: true }) response: Response 25 | ): Promise { 26 | const accessToken = await this.authService.signIn(signInDto); 27 | 28 | response.cookie(TOKEN_KEY, accessToken, { 29 | httpOnly: true, 30 | sameSite: 'strict', 31 | secure: this.envService.isProduction ?? false 32 | }); 33 | 34 | return null; 35 | } 36 | 37 | @SkipAuth() 38 | @HttpCode(HttpStatus.CREATED) 39 | @Post('register') 40 | async signUp(@Body() signUpDto: SignUpDto): Promise { 41 | return await this.authService.signUp(signUpDto); 42 | } 43 | 44 | @HttpCode(HttpStatus.OK) 45 | @Post('logout') 46 | async signOut(@Res({ passthrough: true }) response: Response): Promise { 47 | await this.authService.signOut(); 48 | 49 | response.clearCookie(TOKEN_KEY); 50 | 51 | return null; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CryptModule } from '@shared/crypt'; 4 | import { EnvModule } from '@shared/env'; 5 | import { TokenModule } from '@shared/token'; 6 | import { UserModule } from '@features/user'; 7 | 8 | import { AuthController } from './auth.controller'; 9 | import { AuthService } from './auth.service'; 10 | 11 | @Module({ 12 | imports: [CryptModule, EnvModule, TokenModule, UserModule], 13 | controllers: [AuthController], 14 | providers: [AuthService] 15 | }) 16 | export class AuthModule {} 17 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Scope, 4 | Inject, 5 | ForbiddenException, 6 | UnauthorizedException 7 | } from '@nestjs/common'; 8 | import { REQUEST } from '@nestjs/core'; 9 | import { Request } from 'express'; 10 | 11 | import { ERRORS } from '@shared/constants'; 12 | import { CryptService } from '@shared/crypt'; 13 | import { TokenService } from '@shared/token'; 14 | import { UserService } from '@features/user'; 15 | 16 | import { Auth } from './auth.type'; 17 | 18 | @Injectable({ scope: Scope.REQUEST }) 19 | export class AuthService { 20 | constructor( 21 | @Inject(REQUEST) private readonly request: Request, 22 | private readonly cryptService: CryptService, 23 | private readonly tokenService: TokenService, 24 | private readonly userService: UserService 25 | ) {} 26 | 27 | async signIn(data: Auth.UserCredentials): Promise { 28 | let user: Auth.RegisteredUser; 29 | 30 | user = await this.userService.update( 31 | { 32 | identifier: data.email 33 | }, 34 | ['password'] 35 | ); 36 | 37 | if (!(await this.cryptService.isPasswordCorrect(data.password, user.password))) { 38 | throw new ForbiddenException(ERRORS.WRONG_PASSWORD); 39 | } 40 | 41 | user = await this.userService.update({ 42 | lastLogin: new Date(), 43 | identifier: user.id 44 | }); 45 | 46 | return await this.tokenService.createAccessToken(user); 47 | } 48 | 49 | async signUp(data: Auth.SignUpPayload): Promise { 50 | const { email, password, confirmPassword } = data; 51 | 52 | if (password != confirmPassword) { 53 | throw new UnauthorizedException(ERRORS.WRONG_CONFIRM_PASSWORD); 54 | } 55 | 56 | return await this.userService.create({ email, password }); 57 | } 58 | 59 | async signOut(): Promise { 60 | const existingUser = await this.userService.getById({ id: this.request.user.id }); 61 | 62 | if (!existingUser) { 63 | throw new ForbiddenException(ERRORS.USER_NOT_FOUND); 64 | } 65 | 66 | this.tokenService.invalidateAccessToken(existingUser.id); 67 | 68 | return null; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/auth.type.ts: -------------------------------------------------------------------------------- 1 | import { Users } from '@features/user'; 2 | import { Tokens } from '@shared/token'; 3 | 4 | export declare namespace Auth { 5 | type UserCredentials = { 6 | readonly email: Users.Entity['email']; 7 | readonly password: Users.Entity['password']; 8 | }; 9 | 10 | type SignUpPayload = UserCredentials & { 11 | readonly confirmPassword: Users.Entity['password']; 12 | }; 13 | 14 | type AccessToken = Tokens.AccessToken; 15 | 16 | type RegisteredUser = Users.CleanedEntity; 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sign-in.dto'; 2 | 3 | export * from './sign-up.dto'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/dtos/sign-in.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, IsDefined } from 'class-validator'; 2 | 3 | import type { User } from '@shared/database'; 4 | 5 | export class SignInDto { 6 | @IsEmail() 7 | readonly email: User['email']; 8 | 9 | @IsString() 10 | @IsDefined() 11 | readonly password: User['password']; 12 | } 13 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/dtos/sign-up.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEmail, IsStrongPassword } from 'class-validator'; 2 | 3 | import type { User } from '@shared/database'; 4 | 5 | export class SignUpDto { 6 | @IsEmail() 7 | readonly email: User['email']; 8 | 9 | @IsStrongPassword({ 10 | minLength: 8, 11 | minLowercase: 1, 12 | minUppercase: 1, 13 | minNumbers: 1, 14 | minSymbols: 1 15 | }) 16 | @IsString() 17 | readonly password: User['password']; 18 | 19 | @IsString() 20 | readonly confirmPassword: User['password']; 21 | } 22 | -------------------------------------------------------------------------------- /apps/backend/src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.module'; 2 | 3 | export * from './auth.service'; 4 | 5 | export * from './auth.type'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/dtos/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsBooleanString } from 'class-validator'; 2 | 3 | import { Templates } from '../template.type'; 4 | 5 | export class CreateTemplateDto implements Templates.CreatePayload { 6 | @IsString() 7 | name: Templates.CreatePayload['name']; 8 | 9 | @IsString() 10 | tree: Templates.CreatePayload['tree']; 11 | 12 | @IsBooleanString() 13 | isPublic: Templates.CreatePayload['isPublic']; 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/dtos/get-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | import { Templates } from '../template.type'; 4 | 5 | export class GetTemplateByIdDto implements Templates.IdPayload { 6 | @IsString() 7 | id: Templates.IdPayload['id']; 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.dto'; 2 | 3 | export * from './get-by-id.dto'; 4 | 5 | export * from './update.dto'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/dtos/update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional, IsString, IsInt } from 'class-validator'; 2 | 3 | import { Templates } from '../template.type'; 4 | 5 | export class UpdateTemplateDto implements Omit { 6 | @IsString() 7 | @IsOptional() 8 | name?: Templates.UpdatePayload['name']; 9 | 10 | @IsString() 11 | @IsOptional() 12 | tree?: Templates.UpdatePayload['tree']; 13 | 14 | @IsBoolean() 15 | @IsOptional() 16 | isPublic?: Templates.UpdatePayload['isPublic']; 17 | 18 | @IsInt() 19 | @IsOptional() 20 | likes?: Templates.UpdatePayload['likes']; 21 | } 22 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/index.ts: -------------------------------------------------------------------------------- 1 | export * from './template.service'; 2 | 3 | export * from './template.module'; 4 | 5 | export * from './template.type'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/template.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common'; 2 | 3 | import { TemplateService } from './template.service'; 4 | import { Templates } from './template.type'; 5 | import { CreateTemplateDto, GetTemplateByIdDto, UpdateTemplateDto } from '@features/template/dtos'; 6 | 7 | @Controller('templates') 8 | export class TemplateController { 9 | constructor(private readonly templateService: TemplateService) {} 10 | 11 | @Get() 12 | async getAllTemplates(): Promise { 13 | return this.templateService.getAll(); 14 | } 15 | 16 | @Post() 17 | async createTemplate(@Body() createDto: CreateTemplateDto): Promise { 18 | return this.templateService.create(createDto); 19 | } 20 | 21 | @Get(':id') 22 | async getTemplateById(@Param() routeParams: GetTemplateByIdDto): Promise { 23 | return this.templateService.getById(routeParams); 24 | } 25 | 26 | @Post(':id') 27 | async updateTemplate( 28 | @Param() routeParams: GetTemplateByIdDto, 29 | @Body() updateDto: UpdateTemplateDto 30 | ): Promise { 31 | return this.templateService.update({ 32 | ...updateDto, 33 | id: routeParams.id 34 | }); 35 | } 36 | 37 | @Delete(':id') 38 | async deleteTemplate(@Param() routeParams: GetTemplateByIdDto): Promise { 39 | return this.templateService.delete(routeParams); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/template.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { DatabaseService } from '@shared/database'; 4 | 5 | import { TemplateController } from './template.controller'; 6 | import { TemplateService } from './template.service'; 7 | 8 | @Module({ 9 | controllers: [TemplateController], 10 | providers: [TemplateService, DatabaseService], 11 | exports: [TemplateService] 12 | }) 13 | export class TemplateModule {} 14 | -------------------------------------------------------------------------------- /apps/backend/src/features/template/template.type.ts: -------------------------------------------------------------------------------- 1 | import { Template } from '@shared/database'; 2 | 3 | export declare namespace Templates { 4 | type Entity = Template; 5 | 6 | type IdPayload = { 7 | id: Template['id']; 8 | }; 9 | 10 | type CreatePayload = Pick; 11 | 12 | type UpdatePayload = Partial>; 13 | 14 | type DeletePayload = IdPayload; 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/dtos/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, IsStrongPassword } from 'class-validator'; 2 | 3 | import { Users } from '../user.type'; 4 | 5 | export class CreateUserDto implements Users.CreatePayload { 6 | @IsEmail() 7 | readonly email: Users.Entity['email']; 8 | 9 | @IsStrongPassword({ 10 | minLength: 8, 11 | minLowercase: 1, 12 | minUppercase: 1, 13 | minNumbers: 1, 14 | minSymbols: 1 15 | }) 16 | @IsString() 17 | readonly password: Users.Entity['password']; 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/dtos/delete.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsDefined } from 'class-validator'; 2 | 3 | import { Users } from '../user.type'; 4 | 5 | export class DeleteUserDto implements Omit { 6 | @IsString() 7 | @IsDefined() 8 | readonly password: Users.Entity['password']; 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/dtos/get-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId } from 'class-validator'; 2 | 3 | import { Users } from '../user.type'; 4 | 5 | export class GetUserByIdDto implements Users.IdPayload { 6 | @IsMongoId() 7 | readonly id: Users.Entity['id']; 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.dto'; 2 | 3 | export * from './update.dto'; 4 | 5 | export * from './delete.dto'; 6 | 7 | export * from './get-by-id.dto'; 8 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/dtos/update.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsStrongPassword, 3 | IsBoolean, 4 | IsEmail, 5 | IsString, 6 | IsDate, 7 | IsIn, 8 | IsOptional, 9 | ValidateNested 10 | } from 'class-validator'; 11 | 12 | import { Role, Membership } from '@shared/database'; 13 | 14 | import { Users } from '../user.type'; 15 | 16 | export class UpdateUserDto implements Omit { 17 | @IsOptional() 18 | @IsStrongPassword({ 19 | minLength: 8, 20 | minLowercase: 1, 21 | minUppercase: 1, 22 | minNumbers: 1, 23 | minSymbols: 1 24 | }) 25 | password: Users.Entity['password']; 26 | 27 | @IsOptional() 28 | @IsBoolean() 29 | isVerified: Users.Entity['isVerified']; 30 | 31 | @IsOptional() 32 | @IsEmail() 33 | email: Users.Entity['email']; 34 | 35 | @IsOptional() 36 | @IsString() 37 | username: Users.Entity['username']; 38 | 39 | @IsOptional() 40 | @IsIn([Role.User, Role.Admin]) 41 | role: Users.Entity['role']; 42 | 43 | @IsOptional() 44 | @IsDate() 45 | lastLogin: Users.Entity['lastLogin']; 46 | 47 | @IsOptional() 48 | @IsString() 49 | firstName: Users.Entity['firstName']; 50 | 51 | @IsOptional() 52 | @IsString() 53 | lastName: Users.Entity['lastName']; 54 | 55 | @IsOptional() 56 | @IsIn([Membership.Free, Membership.Premium]) 57 | membership: Users.Entity['membership']; 58 | 59 | @IsOptional() 60 | @ValidateNested() 61 | profilePicture: Users.Entity['profilePicture']; 62 | } 63 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './remove-sensitives'; 2 | 3 | export * from './user-to-profile'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/helpers/remove-sensitives.ts: -------------------------------------------------------------------------------- 1 | import { userPayloadData } from '../user.type'; 2 | import { Users } from '../user.type'; 3 | 4 | export type Sanitized = T extends Users.Entity ? Users.CleanedEntity : Users.CleanedEntity[]; 5 | 6 | type UniqueUserKey = Users.PayloadData | Users.SensitiveData; 7 | 8 | const isSingle = (data: Users.Entity | Users.Entity[]): data is Users.Entity => 9 | !Array.isArray(data); 10 | 11 | const pickFromSingle = (data: Users.Entity, include: UniqueUserKey[]): Users.CleanedEntity => { 12 | const user = { ...data }; 13 | Object.keys(user).forEach((key) => { 14 | if (!include.includes(key as UniqueUserKey)) { 15 | delete user[key as keyof Users.Entity]; 16 | } 17 | }); 18 | return user as Users.CleanedEntity; 19 | }; 20 | 21 | export function removeSensitives( 22 | data: TData, 23 | sensitivesToInclude?: Users.SensitiveData[] 24 | ): Sanitized { 25 | if (!data) { 26 | return data as Sanitized; 27 | } 28 | 29 | const picked = [...userPayloadData, ...(sensitivesToInclude || [])] as UniqueUserKey[]; 30 | 31 | if (isSingle(data)) { 32 | return pickFromSingle(data, picked) as Sanitized; 33 | } 34 | 35 | return data.map((user) => pickFromSingle(user, picked)) as Sanitized; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/helpers/user-to-profile.ts: -------------------------------------------------------------------------------- 1 | import { userSensitiveData } from '../user.type'; 2 | import { Users } from '../user.type'; 3 | 4 | export function userToProfile(user: Users.Entity): Users.Profile { 5 | const profile = { ...user }; 6 | userSensitiveData.forEach((key) => delete profile[key]); 7 | return profile as Users.Profile; 8 | } 9 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.module'; 2 | 3 | export * from './user.service'; 4 | 5 | export * from './user.type'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Param, 4 | Body, 5 | Get, 6 | Post, 7 | Delete, 8 | HttpCode, 9 | HttpStatus, 10 | UseGuards 11 | } from '@nestjs/common'; 12 | 13 | import { Role } from '@shared/database'; 14 | import { RolesGuard } from '@shared/guards'; 15 | import { Authorize } from '@shared/decorators'; 16 | 17 | import { UserService } from './user.service'; 18 | import { Users } from './user.type'; 19 | import { UpdateUserDto, DeleteUserDto, GetUserByIdDto } from './dtos'; 20 | 21 | @Controller('users') 22 | @UseGuards(RolesGuard) 23 | export class UserController { 24 | constructor(private readonly userService: UserService) {} 25 | 26 | @Authorize(Role.Admin) 27 | @HttpCode(HttpStatus.OK) 28 | @Get() 29 | async getAllUsers(): Promise { 30 | return await this.userService.getAll(); 31 | } 32 | 33 | @HttpCode(HttpStatus.OK) 34 | @Get('me') 35 | async getUserProfile(): Promise { 36 | return await this.userService.getProfile(); 37 | } 38 | 39 | @HttpCode(HttpStatus.OK) 40 | @Get(':id') 41 | async getUserById(@Param() routeParams: GetUserByIdDto): Promise { 42 | return await this.userService.getById({ 43 | id: routeParams.id 44 | }); 45 | } 46 | 47 | @HttpCode(HttpStatus.OK) 48 | @Post(':id') 49 | async updateUser( 50 | @Body() updateDto: UpdateUserDto, 51 | @Param() routeParams: GetUserByIdDto 52 | ): Promise { 53 | return await this.userService.update({ 54 | ...updateDto, 55 | identifier: routeParams.id 56 | }); 57 | } 58 | 59 | @HttpCode(HttpStatus.OK) 60 | @Delete(':id') 61 | async deleteUser( 62 | @Body() deleteDto: DeleteUserDto, 63 | @Param() routeParams: GetUserByIdDto 64 | ): Promise { 65 | return await this.userService.delete({ 66 | ...deleteDto, 67 | id: routeParams.id 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ValidationPipe } from '@/pipes'; 4 | import { DatabaseService } from '@shared/database'; 5 | import { CryptModule } from '@shared/crypt'; 6 | import { TokenModule } from '@shared/token'; 7 | 8 | import { UserService } from './user.service'; 9 | import { UserController } from './user.controller'; 10 | 11 | @Module({ 12 | imports: [CryptModule, TokenModule], 13 | controllers: [UserController], 14 | providers: [DatabaseService, UserService, ValidationPipe], 15 | exports: [UserService] 16 | }) 17 | export class UserModule {} 18 | -------------------------------------------------------------------------------- /apps/backend/src/features/user/user.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@shared/database'; 2 | 3 | export const userSensitiveData = ['password', 'createdAt'] as const; 4 | 5 | export const userPayloadData = ['id', 'email', 'username', 'role'] as const; 6 | 7 | export declare namespace Users { 8 | type Entity = User; 9 | 10 | type SensitiveData = (typeof userSensitiveData)[number]; 11 | 12 | type PayloadData = (typeof userPayloadData)[number]; 13 | 14 | type NotToUpdate = 'createdAt'; 15 | 16 | type Profile = Omit; 17 | 18 | type CleanedEntity = Pick & Partial>; 19 | 20 | type CreatePayload = { 21 | readonly email: User['email']; 22 | readonly password: User['password']; 23 | }; 24 | 25 | type UpdatePayload = Partial> & { 26 | readonly identifier: User['id'] | User['email']; 27 | }; 28 | 29 | type IdPayload = { 30 | readonly id: User['id']; 31 | }; 32 | 33 | type DeletePayload = IdPayload & { 34 | readonly password: User['password']; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /apps/backend/src/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { HttpAdapterHost } from '@nestjs/core'; 3 | 4 | import { ERRORS } from '@shared/constants'; 5 | import { isDevelopment } from '@config/env.config'; 6 | import type { Response } from '@/types'; 7 | 8 | function toSerializableError(value: unknown): unknown { 9 | if (value instanceof Error) { 10 | return { 11 | name: value.name, 12 | message: value.message, 13 | stack: isDevelopment() ? value.stack : undefined 14 | }; 15 | } 16 | 17 | return value; 18 | } 19 | 20 | @Catch() 21 | export class AllExceptionsFilter implements ExceptionFilter { 22 | constructor(private readonly httpAdapterHost: HttpAdapterHost) {} 23 | 24 | catch(exception: unknown, host: ArgumentsHost): void { 25 | let [statusCode, message]: [number, string] = [ 26 | HttpStatus.INTERNAL_SERVER_ERROR, 27 | ERRORS.INTERNAL_SERVER_ERROR 28 | ]; 29 | 30 | if (exception instanceof HttpException) { 31 | statusCode = exception.getStatus() || statusCode; 32 | message = exception.message || message; 33 | } 34 | 35 | const ctx = host.switchToHttp(); 36 | 37 | const responseBody: Response = { 38 | success: false, 39 | error: { 40 | code: statusCode, 41 | message: message, 42 | path: ctx.getRequest().url, 43 | details: toSerializableError(exception) 44 | }, 45 | timestamp: new Date() 46 | }; 47 | 48 | const { httpAdapter } = this.httpAdapterHost; 49 | 50 | httpAdapter.reply(ctx.getResponse(), responseBody, statusCode); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/backend/src/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './all-exceptions.filter'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { envVariablesSchema } from '@config/env.config'; 4 | import { Users } from '@features/user'; 5 | 6 | declare global { 7 | namespace NodeJS { 8 | interface ProcessEnv extends z.infer {} 9 | } 10 | } 11 | 12 | declare module 'express' { 13 | interface Request { 14 | user: { 15 | id: Users.Entity['id']; 16 | role: Users.Entity['role']; 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/backend/src/interceptors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './response.interceptor'; 2 | 3 | export * from './timeout.interceptor'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | import type { Response } from '@/types'; 5 | 6 | @Injectable() 7 | export class ResponseInterceptor implements NestInterceptor> { 8 | intercept(context: ExecutionContext, next: CallHandler): Observable> { 9 | return next.handle().pipe( 10 | map((value): Response => { 11 | return { 12 | success: true, 13 | data: value ?? null, 14 | timestamp: new Date() 15 | }; 16 | }) 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/backend/src/interceptors/timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | RequestTimeoutException 7 | } from '@nestjs/common'; 8 | import { throwError, TimeoutError } from 'rxjs'; 9 | import { catchError, timeout } from 'rxjs/operators'; 10 | 11 | import { REQUEST_TIMEOUT } from '@shared/constants'; 12 | 13 | @Injectable() 14 | export class TimeoutInterceptor implements NestInterceptor { 15 | intercept(context: ExecutionContext, next: CallHandler) { 16 | return next.handle().pipe( 17 | timeout(REQUEST_TIMEOUT), 18 | catchError((error) => { 19 | if (error instanceof TimeoutError) { 20 | return throwError(() => new RequestTimeoutException()); 21 | } 22 | 23 | return throwError(() => error); 24 | }) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { Logger } from '@nestjs/common'; 3 | import type { NestExpressApplication } from '@nestjs/platform-express'; 4 | 5 | import appOptions from '@config/app.config'; 6 | import { environment } from '@config/env.config'; 7 | 8 | import { AppModule } from '@/app'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule, appOptions); 12 | 13 | const logger = app.get(Logger); 14 | 15 | const { PORT, CLIENT_URL, DATABASE_NAME } = environment(); 16 | 17 | app.enableCors({ 18 | origin: CLIENT_URL, 19 | credentials: true 20 | }); 21 | 22 | await app.listen(PORT); 23 | 24 | const appUrl = (await app.getUrl()).replace('[::1]', 'localhost'); 25 | 26 | logger.log(`Connected to the database "${DATABASE_NAME}"`); 27 | logger.log(`Server listening at port ${appUrl}`); 28 | } 29 | 30 | bootstrap() 31 | .then() 32 | .catch((error) => { 33 | console.error(error); 34 | }); 35 | -------------------------------------------------------------------------------- /apps/backend/src/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validation.pipe'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; 2 | import { validate, ValidatorOptions } from 'class-validator'; 3 | import { plainToInstance } from 'class-transformer'; 4 | 5 | import { ERRORS } from '@shared/constants'; 6 | 7 | @Injectable() 8 | export class ValidationPipe implements PipeTransform { 9 | private readonly validatorOptions: ValidatorOptions = { 10 | stopAtFirstError: true 11 | }; 12 | 13 | async transform(value: unknown, { metatype }: ArgumentMetadata) { 14 | if (!metatype || !this.toValidate(metatype)) { 15 | return value; 16 | } 17 | 18 | // TODO: Fix the "an unknown value was passed to the validate function" error 19 | // triggered when passing `this.validatorOptions` as the second argument. 20 | const errors = await validate(plainToInstance(metatype, value)); 21 | 22 | if (errors.length > 0) { 23 | throw new BadRequestException( 24 | `${ERRORS.VALIDATION_ERROR}: ${Object.values(errors[0].constraints)}` 25 | ); 26 | } 27 | 28 | return value; 29 | } 30 | 31 | private toValidate(metatype: ArgumentMetadata['metatype']): boolean { 32 | const types: ArgumentMetadata['metatype'][] = [String, Boolean, Number, Array, Object]; 33 | return !types.includes(metatype); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/backend/src/plugins/prisma-client/index.ts: -------------------------------------------------------------------------------- 1 | export * from '.prisma/client'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/shared/constants/errors.ts: -------------------------------------------------------------------------------- 1 | const ERRORS: Record = { 2 | //400 3 | INVALID_TEMPLATE: 'Invalid template', 4 | 5 | // 401 6 | WRONG_CONFIRM_PASSWORD: 'Passwords not match', 7 | TOKEN_REQUIRED: 'Access token required', 8 | TOKEN_INVALID: 'Invalid authorization token', 9 | TOKEN_EXPIRED: 'Access token expired', 10 | TOKEN_PAYLOAD_REQUIRED: 'Access token payload required', 11 | 12 | // 402 13 | SUBSCRIPTION_REQUIRED: 'Subscription required', 14 | SUBSCRIPTION_EXPIRED: 'Subscription expired', 15 | SUBSCRIPTION_CANCELED: 'Subscription canceled', 16 | 17 | // 403 18 | WRONG_PASSWORD: 'Wrong password', 19 | RESOURCE_NOT_ALLOWED: 'Resource not allowed', 20 | USER_NOT_ALLOWED: 'User not allowed', 21 | 22 | // 404 23 | USER_NOT_FOUND: 'User not found', 24 | WEBSITE_NOT_FOUND: 'Website not found', 25 | TEMPLATE_NOT_FOUND: 'Template not found', 26 | 27 | // 409 28 | USER_EXISTS: 'User already exists', 29 | 30 | // 417 31 | EXPECTATION_FAILED: 'Expectation failed', 32 | 33 | // 422 34 | VALIDATION_ERROR: 'Payload validation error', 35 | 36 | // 500 37 | INTERNAL_SERVER_ERROR: 'Something went wrong' 38 | }; 39 | 40 | // For auto-completion 41 | // Remember to update this type when you add a new custom error 42 | type Errors = 43 | | 'INVALID_TEMPLATE' 44 | | 'WRONG_CONFIRM_PASSWORD' 45 | | 'TOKEN_REQUIRED' 46 | | 'TOKEN_INVALID' 47 | | 'TOKEN_EXPIRED' 48 | | 'TOKEN_PAYLOAD_REQUIRED' 49 | | 'SUBSCRIPTION_REQUIRED' 50 | | 'SUBSCRIPTION_EXPIRED' 51 | | 'SUBSCRIPTION_CANCELED' 52 | | 'WRONG_PASSWORD' 53 | | 'RESOURCE_NOT_ALLOWED' 54 | | 'USER_NOT_ALLOWED' 55 | | 'USER_NOT_FOUND' 56 | | 'WEBSITE_NOT_FOUND' 57 | | 'TEMPLATE_NOT_FOUND' 58 | | 'USER_EXISTS' 59 | | 'EXPECTATION_FAILED' 60 | | 'VALIDATION_ERROR' 61 | | 'INTERNAL_SERVER_ERROR'; 62 | 63 | export default ERRORS; 64 | -------------------------------------------------------------------------------- /apps/backend/src/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export { default as REQUEST_TIMEOUT } from './timeout'; 2 | 3 | export { default as ERRORS } from './errors'; 4 | 5 | export { default as TOKEN_KEY } from './token-key'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/shared/constants/timeout.ts: -------------------------------------------------------------------------------- 1 | const REQUEST_TIMEOUT = 7000; 2 | 3 | export default REQUEST_TIMEOUT; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/constants/token-key.ts: -------------------------------------------------------------------------------- 1 | const TOKEN_KEY = 'access_token'; 2 | 3 | export default TOKEN_KEY; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/crypt/crypt.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { EnvModule } from '@shared/env'; 4 | 5 | import { CryptService } from './crypt.service'; 6 | 7 | @Module({ 8 | imports: [EnvModule], 9 | providers: [CryptService], 10 | exports: [CryptService] 11 | }) 12 | export class CryptModule {} 13 | -------------------------------------------------------------------------------- /apps/backend/src/shared/crypt/crypt.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | import { handleWithInternalError } from '@/utils'; 5 | import { EnvService } from '@shared/env'; 6 | 7 | @Injectable() 8 | export class CryptService { 9 | constructor(private readonly envService: EnvService) {} 10 | 11 | async hashPassword(password: string): Promise { 12 | try { 13 | const salt = await bcrypt.genSalt(parseInt(this.envService.get('CRYPT_SALT_ROUNDS'))); 14 | return await bcrypt.hash(password, salt); 15 | } catch (error) { 16 | handleWithInternalError(error); 17 | } 18 | } 19 | 20 | async isPasswordCorrect(raw_password: string, encrypted_password: string): Promise { 21 | try { 22 | return await bcrypt.compare(raw_password, encrypted_password); 23 | } catch (error) { 24 | handleWithInternalError(error); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/shared/crypt/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crypt.module'; 2 | 3 | export * from './crypt.service'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common'; 2 | import { DatabaseClient } from './database.types'; 3 | 4 | @Injectable() 5 | export class DatabaseService extends DatabaseClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect(); 8 | } 9 | 10 | async enableShutdownHooks(app: INestApplication) { 11 | this.$on('beforeExit', async () => { 12 | await app.close(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/backend/src/shared/database/database.types.ts: -------------------------------------------------------------------------------- 1 | export * from '@plugins/prisma-client'; 2 | 3 | export { PrismaClient as DatabaseClient, Prisma as Database } from '@plugins/prisma-client'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database.types'; 2 | 3 | export * from './database.service'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/decorators/authorize.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '@shared/database'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Authorize = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /apps/backend/src/shared/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorize.decorator'; 2 | 3 | export * from './skip-auth.decorator'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/decorators/skip-auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const SKIP_AUTH_KEY = 'skip-auth'; 4 | 5 | export const SkipAuth = () => SetMetadata(SKIP_AUTH_KEY, true); 6 | -------------------------------------------------------------------------------- /apps/backend/src/shared/env/env.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import { EnvService } from './env.service'; 5 | 6 | @Module({ 7 | providers: [EnvService], 8 | exports: [EnvService] 9 | }) 10 | export class EnvModule extends ConfigModule {} 11 | -------------------------------------------------------------------------------- /apps/backend/src/shared/env/env.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | import { isDevelopment, isProduction, isTest } from '@config/env.config'; 5 | 6 | @Injectable() 7 | export class EnvService extends ConfigService { 8 | constructor() { 9 | super(); 10 | } 11 | 12 | get(property: T): NodeJS.ProcessEnv[T] { 13 | return super.get(property as string); 14 | } 15 | 16 | get isDevelopment(): boolean { 17 | return isDevelopment(); 18 | } 19 | 20 | get isProduction(): boolean { 21 | return isProduction(); 22 | } 23 | 24 | get isTest(): boolean { 25 | return isTest(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/shared/env/index.ts: -------------------------------------------------------------------------------- 1 | export { EnvService } from './env.service'; 2 | 3 | export { EnvModule } from './env.module'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/shared/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './roles'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/shared/guards/roles.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | import { Role } from '@shared/database'; 5 | import { extractRequestFromContext } from '@/utils'; 6 | 7 | @Injectable() 8 | export class RolesGuard implements CanActivate { 9 | constructor(private readonly reflector: Reflector) {} 10 | 11 | canActivate(context: ExecutionContext): boolean { 12 | const roles = this.reflector.get('roles', context.getHandler()); 13 | 14 | if (!roles) { 15 | return true; 16 | } 17 | 18 | const request = extractRequestFromContext(context); 19 | const user = request.user; 20 | 21 | if (!user) { 22 | return false; 23 | } 24 | 25 | return roles.includes(user.role); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/shared/token/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.service'; 2 | 3 | export * from './token.module'; 4 | 5 | export * from './token.type'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/shared/token/token.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | 4 | import { EnvService } from '@shared/env'; 5 | 6 | import { TokenService } from './token.service'; 7 | 8 | @Module({ 9 | imports: [JwtModule], 10 | providers: [TokenService, EnvService], 11 | exports: [TokenService] 12 | }) 13 | export class TokenModule {} 14 | -------------------------------------------------------------------------------- /apps/backend/src/shared/token/token.type.ts: -------------------------------------------------------------------------------- 1 | import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; 2 | 3 | import { Users } from '@features/user'; 4 | 5 | export declare namespace Tokens { 6 | type AccessToken = string; 7 | 8 | type WhiteList = Map; 9 | 10 | type SignOptions = JwtSignOptions & JwtVerifyOptions & { expiresIn: string }; 11 | } 12 | -------------------------------------------------------------------------------- /apps/backend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './response.type'; 2 | -------------------------------------------------------------------------------- /apps/backend/src/types/response.type.ts: -------------------------------------------------------------------------------- 1 | import type { ApiResponse } from '@react-site-editor/types'; 2 | 3 | export type Response = ApiResponse; 4 | -------------------------------------------------------------------------------- /apps/backend/src/utils/colored-http-logs.ts: -------------------------------------------------------------------------------- 1 | enum MethodsColorsMap { 2 | GET = '\x1b[32m', 3 | POST = '\x1b[33m', 4 | PUT = '\x1b[36m', 5 | DELETE = '\x1b[31m', 6 | PATCH = '\x1b[35m', 7 | OPTIONS = '\x1b[34m', 8 | HEAD = '\x1b[37m', 9 | UNKNOWN = '\x1b[0m' 10 | } 11 | 12 | const isHttpMethod = (str: string): str is keyof typeof MethodsColorsMap => { 13 | return str in MethodsColorsMap; 14 | }; 15 | 16 | const getMethodColor = (method: string): string => { 17 | if (isHttpMethod(method)) { 18 | return MethodsColorsMap[method]; 19 | } 20 | 21 | return MethodsColorsMap.UNKNOWN; 22 | }; 23 | 24 | const getStatusCodeColor = (statusCode: number): string => { 25 | if (statusCode >= 500) { 26 | return '\x1b[33m'; 27 | } 28 | 29 | if (statusCode >= 400) { 30 | return '\x1b[31m'; 31 | } 32 | 33 | if (statusCode >= 300) { 34 | return '\x1b[36m'; 35 | } 36 | 37 | if (statusCode >= 200) { 38 | return '\x1b[32m'; 39 | } 40 | 41 | return '\x1b[0m'; 42 | }; 43 | 44 | export function colorMethod(method: string): string { 45 | return `${getMethodColor(method)}${method}\x1b[0m`; 46 | } 47 | 48 | export function colorStatusCode(statusCode: number): string { 49 | return `${getStatusCodeColor(statusCode)}${statusCode}\x1b[0m`; 50 | } 51 | 52 | export function colorNotImportant(text: string): string { 53 | return `\x1b[2m${text}\x1b[0m`; 54 | } 55 | -------------------------------------------------------------------------------- /apps/backend/src/utils/handle-with-internal-error.ts: -------------------------------------------------------------------------------- 1 | import { Logger, InternalServerErrorException } from '@nestjs/common'; 2 | 3 | import { ERRORS } from '@shared/constants'; 4 | 5 | const logger = new Logger('Shared:HandleInternalError'); 6 | 7 | export function handleWithInternalError(error: unknown): InternalServerErrorException { 8 | logger.error(error); 9 | throw new InternalServerErrorException(ERRORS.INTERNAL_SERVER_ERROR); 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './colored-http-logs'; 2 | 3 | export * from './request-from-context'; 4 | 5 | export * from './handle-with-internal-error'; 6 | -------------------------------------------------------------------------------- /apps/backend/src/utils/request-from-context.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from '@nestjs/common'; 2 | import type { Request } from 'express'; 3 | 4 | export function extractRequestFromContext(context: ExecutionContext): Request { 5 | return context.switchToHttp().getRequest(); 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "sourceMap": true, 7 | "strictNullChecks": false, 8 | "strictBindCallApply": false, 9 | "noFallthroughCasesInSwitch": false, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "preserveSymlinks": false, 13 | "declaration": true, 14 | "outDir": "dist", 15 | "baseUrl": ".", 16 | "noEmit": false, 17 | "incremental": true, 18 | "paths": { 19 | "*": ["./node_modules/*"], 20 | "@/*": ["./src/*"], 21 | "@config/*": ["./src/config/*"], 22 | "@features/*": ["./src/features/*"], 23 | "@plugins/*": ["./src/plugins/*"], 24 | "@shared/*": ["./src/shared/*"], 25 | "@utils/*": ["./src/utils/*"] 26 | } 27 | }, 28 | "include": ["./**/*"] 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/.env.example: -------------------------------------------------------------------------------- 1 | APP_NODE_ENV= 2 | APP_PORT= 3 | APP_API_URL= 4 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # React-Site-Editor - Frontend 2 | 3 | ## Description 4 | -------------------------------------------------------------------------------- /apps/frontend/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | underscores_in_headers on; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index index.html; 9 | } 10 | 11 | 12 | #location /docs { 13 | # proxy_pass http://api/docs; 14 | #} 15 | 16 | #location /api { 17 | # rewrite ^/api(.*)$ $1 break; 18 | # proxy_pass http://api/; 19 | #} 20 | 21 | 22 | 23 | error_page 404 =200 /index.html; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Site Editor 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-site-editor/frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "description": "React Site Editor website frontend", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "start": "vite", 12 | "scripts:init": "find ./scripts/ -type f -iname \"*.sh\" -exec chmod +x {} \\;", 13 | "icons": "pnpm scripts:init && ./scripts/load-icons.sh" 14 | }, 15 | "dependencies": { 16 | "@react-site-editor/functions": "0.0.1", 17 | "@react-site-editor/types": "0.0.1", 18 | "@react-site-editor/ui": "0.0.1", 19 | "@reduxjs/toolkit": "^1.9.3", 20 | "axios": "^1.5.0", 21 | "mitt": "^1.2.0", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-redux": "^8.0.5", 25 | "react-router-dom": "^6.8.1", 26 | "react-sortablejs": "^6.1.4", 27 | "react-tooltip": "^5.8.3", 28 | "sortablejs": "^1.15.0", 29 | "swr": "^2.2.2", 30 | "web-vitals": "^2.1.4" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18.0.27", 34 | "@types/react-dom": "^18.0.10", 35 | "@types/sortablejs": "^1.15.1", 36 | "@vitejs/plugin-react": "^3.1.0", 37 | "autoprefixer": "^10.4.13", 38 | "postcss": "^8.4.21", 39 | "tailwindcss": "^3.2.6", 40 | "ts-node": "^10.0.0", 41 | "typescript": "^4.9.3", 42 | "vite": "^4.4.9" 43 | }, 44 | "homepage": "https://rse.frelya.com" 45 | } 46 | -------------------------------------------------------------------------------- /apps/frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /apps/frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/frontend/scripts/load-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$(pwd | awk -F/ '{print $NF}')" != "frontend" ] 4 | then 5 | echo -e "\033[31mError: Please run this script from the 'apps/frontend' folder or the project root directory\033[39m \n" 6 | exit 1 7 | fi 8 | 9 | BASE_DIR=. 10 | 11 | INTER_FILE=${BASE_DIR}/scripts/output 12 | OUTPUT_FILE=${BASE_DIR}/src/types/icons.type.ts 13 | SCRIPT_FILE=${BASE_DIR}/scripts/modules/loadIcons.ts 14 | 15 | REQUIRED_FILE=../../packages/functions/src/stringutils/pascalToKebab.ts 16 | 17 | # Generate a single typescript file containing the pascalToKebab function 18 | 19 | # Step1: add the pascalToKebab function 20 | cat $REQUIRED_FILE > ${INTER_FILE}.ts 21 | 22 | # Step2: add a blank line 23 | echo >> ${INTER_FILE}.ts 24 | 25 | # Step3: add the loadIcons script, by removing the import of 26 | # pascalToKebab at the top of the file 27 | tail -n +2 $SCRIPT_FILE >> ${INTER_FILE}.ts 28 | 29 | # transpile... 30 | tsc ${INTER_FILE}.ts && 31 | # rename... 32 | cp ${INTER_FILE}.js ${INTER_FILE}.cjs && 33 | # run.. 34 | node ${INTER_FILE}.cjs 35 | # clean... 36 | rm ${INTER_FILE}* && 37 | # format... 38 | npx eslint $OUTPUT_FILE --fix 39 | 40 | echo -e "\033[32m\nIcons types definition generated successfully !\033[39m" 41 | -------------------------------------------------------------------------------- /apps/frontend/scripts/modules/loadIcons.ts: -------------------------------------------------------------------------------- 1 | import { pascalToKebab } from '@react-site-editor/functions'; 2 | import * as fs from 'fs'; 3 | 4 | /* 5 | * This script must mandatory be run from: 6 | * - either from the root of the project using the command: 7 | * > pnpm frontend:icons 8 | * - or from the frontend directory using the command: 9 | * > pnpm icons 10 | */ 11 | const iconsDirectories = ['./src/components/Icons', './../../packages/ui/src/components/icons']; 12 | const typesFile = './src/types/icons.type.ts'; 13 | 14 | const iconNames: string[] = []; 15 | 16 | iconsDirectories.forEach((iconsDirectory) => { 17 | const files: string[] = fs.readdirSync(iconsDirectory); 18 | 19 | files.forEach((file) => { 20 | iconNames.push(`'${pascalToKebab(file.split('.')[0])}'`); 21 | }); 22 | 23 | console.log(`\n> ${files.length} icons found in '${iconsDirectory}'`); 24 | }); 25 | 26 | const typeDefinition = `export type IconName = ${iconNames.join(' | ')} ;`; 27 | 28 | fs.writeFileSync(typesFile, typeDefinition); 29 | 30 | console.log(`\n> ${iconNames.length} icons found in total:`); 31 | 32 | for (const name of iconNames) { 33 | console.log(` --> ${name.slice(1, name.length - 1)}`); 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import { Tooltip } from 'react-tooltip'; 3 | import { MittProvider } from '@components/Providers'; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/assets/icons/box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frelya/react-site-editor/0c88aa6b2cb0e80943e5c76870cc7dae1fe53dd4/apps/frontend/src/assets/icons/box.png -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/Buttons/EditorButton.tsx: -------------------------------------------------------------------------------- 1 | interface EditorButtonProps extends React.PropsWithChildren { 2 | textColor?: string; 3 | backgroundColor?: string; 4 | orientation?: 'horizontal' | 'vertical'; 5 | onClick?: () => void; 6 | } 7 | 8 | const EditorButton: React.FunctionComponent = (props) => { 9 | const customStyle = { 10 | color: props.textColor || 'white', 11 | backgroundColor: props.backgroundColor 12 | }; 13 | 14 | const handleButtonClick = () => { 15 | if (props.onClick) { 16 | props.onClick(); 17 | } 18 | }; 19 | 20 | return ( 21 |
22 | {props.children} 23 |
24 | ); 25 | }; 26 | 27 | const styleClasses = { 28 | button: 'py-2 px-4 flex items-center justify-evenly bg-blue-500 font-bold rounded cursor-pointer hover:bg-blue-600' 29 | }; 30 | 31 | export default EditorButton; 32 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/Buttons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as EditorButton } from './EditorButton'; 2 | export * from './EditorButton'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/ContextMenu/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import type { ContextMenuAction } from '@/types'; 2 | import ContextMenuItem from './ContextMenuItem'; 3 | 4 | interface ContextMenuProps { 5 | top: number; 6 | left: number; 7 | actions: ContextMenuAction[]; 8 | } 9 | 10 | const ContextMenu: React.FunctionComponent = (props) => { 11 | return ( 12 |
18 | {props.actions.map((action, index) => { 19 | return ; 20 | })} 21 |
22 | ); 23 | }; 24 | 25 | const styleClasses = { 26 | container: 27 | 'fixed bg-black bg-opacity-70 flex flex-col backdrop-blur-lg rounded-lg py-4 text-white z-50' 28 | }; 29 | 30 | export default ContextMenu; 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/ContextMenu/ContextMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import type { ContextMenuAction } from '@/types'; 2 | import { Icon } from '@components/Decorators'; 3 | 4 | interface ContextMenuItemProps { 5 | action: ContextMenuAction; 6 | } 7 | 8 | const ContextMenuItem: React.FunctionComponent = (props) => { 9 | const handleClick = (event: React.MouseEvent) => { 10 | props.action.handler(event); 11 | }; 12 | 13 | return ( 14 |
15 | {props.action.icon && } {props.action.label} 16 |
17 | ); 18 | }; 19 | 20 | const styleClasses = { 21 | container: 'py-2 px-4 border-y border-functional-grey flex gap-4 min-w-[200px] cursor-pointer' 22 | }; 23 | 24 | export default ContextMenuItem; 25 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ContextMenu } from './ContextMenu'; 2 | export * from './ContextMenu'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ContextMenu'; 2 | 3 | export * from './Buttons'; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ComponentsList/ComponentsList.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos } from '@react-site-editor/types'; 2 | import GroupsList from './GroupsList'; 3 | 4 | interface ComponentsListProps { 5 | elements: ComponentInfos[]; 6 | searchQuery: string; 7 | } 8 | 9 | const ComponentsList: React.FunctionComponent = (props) => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | const styleClasses = { 18 | container: 'h-full flex flex-col items-center justify-start' 19 | }; 20 | 21 | export default ComponentsList; 22 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ComponentsList/Group.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import type { ComponentInfos } from '@react-site-editor/types'; 3 | import { Icon } from '@components/Decorators'; 4 | import GroupItem from './GroupItem'; 5 | 6 | interface GroupProps { 7 | label: string; 8 | group: ComponentInfos[]; 9 | } 10 | 11 | const Group: React.FunctionComponent = (props) => { 12 | const [isVisible, setIsVisible] = useState(false); 13 | 14 | const toggleVisibility = () => { 15 | setIsVisible(!isVisible); 16 | }; 17 | 18 | return ( 19 |
  • 20 |
    21 | 27 |

    28 | {props.label} ({props.group.length}) 29 |

    30 |
    31 |
      35 | {props.group.map((element) => { 36 | return ( 37 |
    • 38 | 39 |
    • 40 | ); 41 | })} 42 |
    43 |
  • 44 | ); 45 | }; 46 | 47 | const styleClasses = { 48 | container: 'cursor-pointer w-full h-fit', 49 | label: 'relative text-center w-11/12 font-bold border-2 border-gray-300 p-2.5 mx-auto shadow-md', 50 | icon: 'w-6 h-6 absolute left-2 transition-all duration-300', 51 | iconDown: '-rotate-90', 52 | iconRight: '-rotate-180', 53 | itemsList: 54 | 'w-11/12 mx-auto h-fit py-5 items-start gap-1 list-none transition-all duration-1000', 55 | itemsListVisible: 'grid grid-cols-auto-fit', 56 | itemsListInvisible: 'hidden', 57 | item: 'cursor-pointer w-fit h-fit' 58 | }; 59 | 60 | export default Group; 61 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ComponentsList/GroupItem.tsx: -------------------------------------------------------------------------------- 1 | import { pascalToSpaced } from '@react-site-editor/functions'; 2 | import type { ComponentInfos } from '@react-site-editor/types'; 3 | import type { IconName } from '@/types'; 4 | import { Icon, Draggable } from '@components/Decorators'; 5 | 6 | interface ComponentsListItemProps { 7 | element: ComponentInfos; 8 | } 9 | 10 | const GroupItem: React.FunctionComponent = (props) => { 11 | const itemName = pascalToSpaced(props.element.name); 12 | 13 | return ( 14 | 15 |
    16 |
    17 | 21 |
    22 |

    {itemName}

    23 |
    24 |
    25 | ); 26 | }; 27 | 28 | const styleClasses = { 29 | container: 30 | 'flex flex-col justify-evenly items-center gap-2 w-24 aspect-square m-1 p-2 border border-gray-300 hover:border-blue-300 bg-[#fafafa]', 31 | iconDiv: 'flex justify-center items-center w-1/2 aspect-square text-gray-500', 32 | icon: 'w-1/2', 33 | text: 'w-full text-center whitespace-nowrap overflow-hidden text-ellipsis' 34 | }; 35 | 36 | export default GroupItem; 37 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ComponentsList/GroupsList.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos } from '@react-site-editor/types'; 2 | import Group from './Group'; 3 | 4 | interface ComponentsListGroupsProps { 5 | elements: ComponentInfos[]; 6 | filter: string; 7 | } 8 | 9 | const GroupsList: React.FunctionComponent = (props) => { 10 | const filteredComponents = props.elements.filter((component) => { 11 | return ( 12 | component.name.toLowerCase().includes(props.filter.toLowerCase()) || 13 | component.group.toLowerCase().includes(props.filter.toLowerCase()) 14 | ); 15 | }); 16 | 17 | const groups = [...new Set(filteredComponents.map((element) => element.group))]; 18 | 19 | const elementsByGroup: Record = {}; 20 | 21 | filteredComponents.forEach((element) => { 22 | if (!elementsByGroup[element.group]) { 23 | elementsByGroup[element.group] = []; 24 | } 25 | 26 | elementsByGroup[element.group].push(element); 27 | }); 28 | 29 | return ( 30 |
      31 | {groups.length > 0 ? ( 32 | groups.map((name) => { 33 | return ; 34 | }) 35 | ) : ( 36 |
      No components found
      37 | )} 38 |
    39 | ); 40 | }; 41 | 42 | const styleClasses = { 43 | container: 'flex flex-col items-center justify-start gap-y-8 w-full list-none', 44 | emptyList: 'text-center text-functional-grey my-10' 45 | }; 46 | 47 | export default GroupsList; 48 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ComponentsList/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ComponentsList } from './ComponentsList'; 2 | export * from './ComponentsList'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/Draggable.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos } from '@react-site-editor/types'; 2 | import boxImage from '@assets/icons/box.png'; 3 | import { useMitt } from '@/hooks'; 4 | 5 | interface DraggableProps { 6 | children: React.ReactElement; 7 | type: string; 8 | target: ComponentInfos['name']; 9 | } 10 | 11 | const Draggable: React.FunctionComponent = (props) => { 12 | const emitter = useMitt(); 13 | 14 | const dragImage = new Image(); 15 | dragImage.src = boxImage; 16 | 17 | const handleDragStart = (event: React.DragEvent) => { 18 | emitter.emit('dragStartEvent'); 19 | 20 | // Set cursor style to "grabbing" while dragging 21 | document.body.style.cursor = 'grabbing'; 22 | 23 | if (props.children) { 24 | event.dataTransfer.setData(props.type, props.target); 25 | 26 | event.dataTransfer.setDragImage(dragImage, 32, 16); 27 | } 28 | }; 29 | 30 | const handleDragEnd = () => { 31 | emitter.emit('dragEndEvent'); 32 | 33 | // Set cursor style back to default after the drag movement 34 | document.body.style.cursor = 'default'; 35 | }; 36 | return ( 37 |
    42 | {props.children} 43 |
    44 | ); 45 | }; 46 | 47 | export default Draggable; 48 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/Droppable.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | interface DroppableProps { 4 | onDrop: (e: React.DragEvent) => void; 5 | onDragEnter: (e: React.DragEvent) => void; 6 | onDragLeave: (e: React.DragEvent) => void; 7 | type: string; 8 | children: React.ReactNode; 9 | } 10 | 11 | const Droppable: React.FunctionComponent = (props) => { 12 | const handleDragOver = useCallback((event: React.DragEvent) => { 13 | event.preventDefault(); 14 | event.dataTransfer.dropEffect = 'move'; 15 | }, []); 16 | 17 | const handleDrop = useCallback((event: React.DragEvent) => { 18 | event.preventDefault(); 19 | props.onDrop(event); 20 | }, []); 21 | 22 | const handleDragLeave = (event: React.DragEvent) => { 23 | props.onDragLeave(event); 24 | }; 25 | 26 | const handleDragEnter = (event: React.DragEvent) => { 27 | if (event.dataTransfer.types.includes(props.type)) { 28 | props.onDragEnter(event); 29 | } 30 | }; 31 | 32 | return ( 33 |
    39 | {props.children} 40 |
    41 | ); 42 | }; 43 | 44 | export default Droppable; 45 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/DynamicComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as allComponent from '@react-site-editor/ui'; 2 | import type { InferredProps, ExposedComponentsMap } from '@react-site-editor/types'; 3 | 4 | interface DynamicComponentProps { 5 | componentName: string; 6 | componentProps: InferredProps; 7 | } 8 | 9 | const DynamicComponent: React.FunctionComponent = (props) => { 10 | const Component = (allComponent as ExposedComponentsMap)[props.componentName]; 11 | 12 | return ; 13 | }; 14 | 15 | export default DynamicComponent; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense, useMemo } from 'react'; 2 | import { kebabToPascal } from '@react-site-editor/functions'; 3 | import type { IconName, TooltipPlace } from '@/types'; 4 | 5 | interface IconProps { 6 | name: IconName; 7 | description?: string; 8 | className?: string; 9 | descriptionPlace?: TooltipPlace; 10 | onClick?: (e?: React.MouseEvent) => void; 11 | } 12 | 13 | const Icon: React.FunctionComponent = (props) => { 14 | const DynamicIcon = useMemo( 15 | () => 16 | props.name.startsWith('ui') 17 | ? lazy( 18 | () => 19 | import( 20 | `../../../../../packages/ui/src/components/icons/${kebabToPascal( 21 | props.name 22 | )}.tsx` 23 | ) 24 | ) 25 | : lazy(() => import(`../Icons/${kebabToPascal(props.name)}.tsx`)), 26 | [props.name] 27 | ); 28 | 29 | const handleIconClickCapture = (event: React.MouseEvent) => { 30 | event.stopPropagation(); 31 | if (props.onClick) { 32 | props.onClick(event); 33 | } 34 | }; 35 | 36 | return ( 37 |
    42 | 43 | 44 | 45 |
    46 | ); 47 | }; 48 | 49 | export default Icon; 50 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/WithContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import type { ContextMenuAction } from '@/types'; 2 | import { useContextMenu } from '@/hooks'; 3 | import { ContextMenu } from '@components/Common'; 4 | 5 | // TODO Renames the component 6 | interface WithContextMenuProps { 7 | children: React.ReactNode | React.ReactNode[]; 8 | actions: ContextMenuAction[]; 9 | className: string; 10 | } 11 | 12 | const WithContextMenu: React.FunctionComponent = (props) => { 13 | // destructure our state and set state functions from our custom hook 14 | const [clicked, setClicked, coords, setCoords] = useContextMenu(); 15 | 16 | const handleContextMenu = (event: React.MouseEvent) => { 17 | event.preventDefault(); 18 | // set our click state to true when a user right clicks 19 | setClicked(true); 20 | // set the x and y coordinates of our users right click 21 | setCoords({ x: event.pageX, y: event.pageY }); 22 | }; 23 | 24 | return ( 25 |
    26 | {props.children} 27 | {clicked && } 28 |
    29 | ); 30 | }; 31 | 32 | export default WithContextMenu; 33 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Decorators/index.ts: -------------------------------------------------------------------------------- 1 | export { default as WithContextMenu } from './WithContextMenu'; 2 | export * from './WithContextMenu'; 3 | 4 | export { default as Draggable } from './Draggable'; 5 | export * from './Draggable'; 6 | 7 | export { default as Droppable } from './Droppable'; 8 | export * from './Droppable'; 9 | 10 | export { default as DynamicComponent } from './DynamicComponent'; 11 | export * from './DynamicComponent'; 12 | 13 | export { default as Icon } from './Icon'; 14 | export * from './Icon'; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/AddCircleLine.tsx: -------------------------------------------------------------------------------- 1 | const AddCircleLine = () => { 2 | return ( 3 | 4 | 5 | 9 | 10 | ); 11 | }; 12 | 13 | export default AddCircleLine; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/ArrowSmallDown.tsx: -------------------------------------------------------------------------------- 1 | const ArrowSmallDown = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default ArrowSmallDown; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/ArrowSmallUp.tsx: -------------------------------------------------------------------------------- 1 | const ArrowSmallUp = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default ArrowSmallUp; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Bars.tsx: -------------------------------------------------------------------------------- 1 | const Bars = () => { 2 | return ( 3 | 4 | 5 | 6 | ); 7 | }; 8 | 9 | export default Bars; 10 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/ChevronLeft.tsx: -------------------------------------------------------------------------------- 1 | const ChevronLeft = () => { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default ChevronLeft; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/CrossMark.tsx: -------------------------------------------------------------------------------- 1 | const CrossMark = () => { 2 | return ( 3 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default CrossMark; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Cubes.tsx: -------------------------------------------------------------------------------- 1 | const Cubes = () => { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default Cubes; 17 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/DesktopScreen.tsx: -------------------------------------------------------------------------------- 1 | const DesktopScreen = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default DesktopScreen; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/GripVertical.tsx: -------------------------------------------------------------------------------- 1 | const GripVertical = () => { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default GripVertical; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/InformationCircle.tsx: -------------------------------------------------------------------------------- 1 | const InformationCircle = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default InformationCircle; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Minus.tsx: -------------------------------------------------------------------------------- 1 | const Minus = () => { 2 | return ( 3 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Minus; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/MobileScreen.tsx: -------------------------------------------------------------------------------- 1 | const MobileScreen = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default MobileScreen; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Option.tsx: -------------------------------------------------------------------------------- 1 | const Option = () => { 2 | return ( 3 | 9 | 13 | 14 | ); 15 | }; 16 | 17 | export default Option; 18 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/RefreshArrows.tsx: -------------------------------------------------------------------------------- 1 | const RefreshArrows = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default RefreshArrows; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Save.tsx: -------------------------------------------------------------------------------- 1 | const Save = () => { 2 | return ( 3 | 4 | 12 | 13 | ); 14 | }; 15 | 16 | export default Save; 17 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Search.tsx: -------------------------------------------------------------------------------- 1 | const Search = () => { 2 | return ( 3 | 9 | 14 | 15 | ); 16 | }; 17 | 18 | export default Search; 19 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/Stack.tsx: -------------------------------------------------------------------------------- 1 | const Stack = () => { 2 | return ( 3 | 10 | 15 | 16 | ); 17 | }; 18 | 19 | export default Stack; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Icons/User.tsx: -------------------------------------------------------------------------------- 1 | const User = () => { 2 | return ( 3 | 4 | 5 | 6 | ); 7 | }; 8 | 9 | export default User; 10 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Preview/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Preview } from './Preview'; 2 | export * from './Preview'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/Property/ColorProperty.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import type { PropertyProps } from '@/types'; 3 | import PropertyWrapper from '@components/PropertyComponents/PropertyWrapper'; 4 | 5 | const ColorProperty: React.FunctionComponent> = (props) => { 6 | const [color, setColor] = useState(props.value); 7 | 8 | const handleInputChange = (event: React.ChangeEvent) => { 9 | const validHexPattern = /^#([0-9a-f]{3}){1,2}$/i; 10 | const newValue = event.target.value; 11 | 12 | setColor(newValue); 13 | 14 | if (!validHexPattern.test(newValue)) { 15 | return; 16 | } 17 | 18 | if (props.onChange) { 19 | props.onChange(newValue); 20 | } 21 | }; 22 | 23 | useEffect(() => { 24 | setColor(props.value as string); 25 | }, [props]); 26 | 27 | return ( 28 | 29 |
    30 | 36 |
    37 | 38 | 44 |
    45 |
    46 |
    47 | ); 48 | }; 49 | 50 | const styleClasses = { 51 | inputDiv: 'flex items-center justify-between w-full p-1', 52 | hexDiv: 'flex items-center justify-center gap-4 w-2/3', 53 | inputPick: 'h-10 aspect-square cursor-pointer', 54 | inputHex: 'w-1/2 h-10 p-1 text-center focus:outline focus:outline-blue-500' 55 | }; 56 | 57 | export default ColorProperty; 58 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/Property/NumberProperty.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import type { NumberControl } from '@react-site-editor/types'; 3 | import type { PropertyProps } from '@/types'; 4 | import PropertyWrapper from '@components/PropertyComponents/PropertyWrapper'; 5 | 6 | const NumberProperty: React.FunctionComponent> = (props) => { 7 | const [value, setValue] = useState(props.value); 8 | 9 | const handleInputChange = (event: React.ChangeEvent) => { 10 | const newValue = Number((event.target as HTMLInputElement).value); 11 | 12 | if (props.spec.min && newValue < props.spec.min) { 13 | setValue(props.spec.min); 14 | return; 15 | } 16 | 17 | if (props.spec.max && newValue > props.spec.max) { 18 | setValue(props.spec.max); 19 | return; 20 | } 21 | 22 | setValue(newValue); 23 | 24 | if (props.onChange) { 25 | props.onChange(newValue); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | setValue(Number(props.value)); 31 | }, [props]); 32 | 33 | return ( 34 | 35 |
    36 | min: {props.spec.min} 37 | 45 | max: {props.spec.max} 46 |
    47 |
    48 | ); 49 | }; 50 | 51 | const styleClasses = { 52 | inputDiv: 'w-full h-10 flex justify-between', 53 | input: 54 | 'relative w-1/3 h-full text-lg p-2 ' + 55 | 'focus:outline-none focus:ring-1 focus:ring-blue-300 focus:border-transparent ' + 56 | 'hover:ring-1 hover:ring-blue-300 ' + 57 | 'number-spin:full-right', 58 | span: 'flex-1 h-full text-lg flex items-center justify-center' 59 | }; 60 | 61 | export default NumberProperty; 62 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/Property/RangeProperty.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import type { RangeControl } from '@react-site-editor/types'; 3 | import type { PropertyProps } from '@/types'; 4 | import PropertyWrapper from '@components/PropertyComponents/PropertyWrapper'; 5 | 6 | const RangeProperty: React.FunctionComponent> = (props) => { 7 | const [size, setSize] = useState(props.value); 8 | 9 | const handleInputChange = (event: React.ChangeEvent) => { 10 | const newValue = Number((event.target as HTMLInputElement).value); 11 | 12 | setSize(newValue); 13 | 14 | if (props.onChange) { 15 | props.onChange(newValue); 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | setSize(props.value as number); 21 | }, [props]); 22 | 23 | return ( 24 | 25 |
    26 | 35 |

    {size}

    36 |
    37 |
    38 | ); 39 | }; 40 | 41 | const styleClasses = { 42 | inputDiv: 'flex justify-evenly w-full p-1', 43 | input: 'w-10/12', 44 | sizeValue: 'flex-1 text-center' 45 | }; 46 | 47 | export default RangeProperty; 48 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/Property/TextProperty.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import type { PropertyProps } from '@/types'; 3 | import PropertyWrapper from '@components/PropertyComponents/PropertyWrapper'; 4 | 5 | const TextProperty: React.FunctionComponent> = (props) => { 6 | const [value, setValue] = useState(props.value); 7 | 8 | const handleInputChange = (event: React.ChangeEvent) => { 9 | const newValue = (event.target as HTMLInputElement).value; 10 | 11 | setValue(newValue); 12 | 13 | if (props.onChange) { 14 | props.onChange(newValue); 15 | } 16 | }; 17 | 18 | useEffect(() => { 19 | setValue(props.value as string); 20 | }, [props]); 21 | 22 | return ( 23 | 24 | 30 | 31 | ); 32 | }; 33 | 34 | const styleClasses = { 35 | input: 'w-full h-10 text-sm p-1 focus:outline focus:outline-blue-500' 36 | }; 37 | 38 | export default TextProperty; 39 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/Property/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ColorProperty } from './ColorProperty'; 2 | export * from './ColorProperty'; 3 | 4 | export { default as GridTemplateProperty } from './GridTemplateProperty'; 5 | export * from './GridTemplateProperty'; 6 | 7 | export { default as NumberProperty } from './NumberProperty'; 8 | export * from './NumberProperty'; 9 | 10 | export { default as RangeProperty } from './RangeProperty'; 11 | export * from './RangeProperty'; 12 | 13 | export { default as TextProperty } from './TextProperty'; 14 | export * from './TextProperty'; 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/PropertyWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { pascalToSpaced, capitalize } from '@react-site-editor/functions'; 2 | import type { PropertyProps } from '@/types'; 3 | 4 | interface PropertyWrapperProps { 5 | name: PropertyProps['name']; 6 | children: React.ReactNode; 7 | } 8 | 9 | const PropertyWrapper: React.FunctionComponent = (props) => { 10 | return ( 11 |
    12 | 13 | {props.children} 14 |
    15 | ); 16 | }; 17 | 18 | const styleClasses = { 19 | container: 'flex flex-col w-11/12 h-fit mx-auto mb-6 pl-2 pb-1 border-l-4 border-gray-300', 20 | label: 'text-sm my-1' 21 | }; 22 | 23 | export default PropertyWrapper; 24 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/components-map.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ColorProperty, 3 | GridTemplateProperty, 4 | NumberProperty, 5 | RangeProperty, 6 | TextProperty 7 | } from './Property'; 8 | 9 | const PROPERTY_COMPONENTS_MAP: Record> = { 10 | COLOR: ColorProperty, 11 | GRID_TEMPLATE: GridTemplateProperty, 12 | NUMBER: NumberProperty, 13 | RANGE: RangeProperty, 14 | TEXT: TextProperty 15 | }; 16 | 17 | export default PROPERTY_COMPONENTS_MAP; 18 | -------------------------------------------------------------------------------- /apps/frontend/src/components/PropertyComponents/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Property'; 2 | 3 | export { default as PROPERTY_COMPONENTS_MAP } from './components-map'; 4 | export * from './components-map'; 5 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Providers/MittProvider.tsx: -------------------------------------------------------------------------------- 1 | import { emitter, MittContext } from '@contexts/mitt'; 2 | 3 | const MittProvider: React.FunctionComponent = ({ children }) => { 4 | return {children}; 5 | }; 6 | 7 | export default MittProvider; 8 | -------------------------------------------------------------------------------- /apps/frontend/src/components/Providers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MittProvider } from './MittProvider'; 2 | export * from './MittProvider'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ReOrganizer/ReOrganizerItem.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { pascalToSpaced } from '@react-site-editor/functions'; 4 | import { useMitt } from '@/hooks'; 5 | import { resetActiveComponent } from '@store/activeComponent/activeComponentSlice'; 6 | import { Icon } from '@components/Decorators'; 7 | 8 | interface ReOrganizerItemProps { 9 | index: number; 10 | name: string; 11 | } 12 | 13 | const ReOrganizerItem: React.FunctionComponent = (props) => { 14 | const dispatch = useDispatch(); 15 | const emitter = useMitt(); 16 | const [isSelected, setIsSelected] = useState(false); 17 | 18 | const handleClick = () => { 19 | dispatch(resetActiveComponent()); 20 | emitter.emit('itemInterfaceClicked', props.index); 21 | setIsSelected(!isSelected); 22 | }; 23 | 24 | emitter.on('itemInterfaceClicked', (index) => { 25 | setIsSelected(index === props.index ? !isSelected : false); 26 | }); 27 | 28 | return ( 29 |
    34 |
    35 | 36 |
    37 |
    {pascalToSpaced(props.name)}
    38 |
    39 | ); 40 | }; 41 | 42 | const styleClasses = { 43 | container: 44 | 'w-11/12 h-12 mx-auto my-2 p-2 border border-gray-400 flex justify-start items-center gap-4 rounded-md cursor-pointer', 45 | containerSelected: 'outline outline-2 outline-slate-500', 46 | icon: 'reorganize w-fit h-fit bg-grey rounded-sm cursor-grab', 47 | name: 'w-fit h-full flex justify-start items-center' 48 | }; 49 | 50 | export default ReOrganizerItem; 51 | -------------------------------------------------------------------------------- /apps/frontend/src/components/ReOrganizer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ReOrganizer } from './ReOrganizer'; 2 | export * from './ReOrganizer'; 3 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/BaseSideBar.tsx: -------------------------------------------------------------------------------- 1 | export enum SideBarScales { 2 | NORMAL = '1', 3 | NARROW = '2' 4 | } 5 | 6 | interface SideBarProps { 7 | visible: boolean; 8 | side: 'left' | 'right'; 9 | scale: SideBarScales; 10 | children?: React.ReactNode | React.ReactNode[]; 11 | } 12 | 13 | const BaseSideBar: React.FunctionComponent = (props) => { 14 | return ( 15 |
    25 | {props.children} 26 |
    27 | ); 28 | }; 29 | 30 | const styleClasses = { 31 | container: 32 | 'flex flex-col h-screen transition-all duration-200 bg-[#f0f0f0] shadow-[0_5px_10px_black]', 33 | scale1: 'w-1/5 min-w-[20%] max-w-[20%]', 34 | scale2: 'w-1/4 min-w-[25%] max-w-[25%]', 35 | invisible: 'hidden', 36 | invisibleSlideLeft: '-translate-x-full', 37 | invisibleSlideRight: 'translate-x-full', 38 | visible: 'block translate-x-0' 39 | }; 40 | 41 | export default BaseSideBar; 42 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { useMitt, useUser } from '@/hooks'; 2 | import { Icon } from '@components/Decorators'; 3 | import { EditorButton } from '@components/Common'; 4 | import BaseSideBar, { SideBarScales } from './BaseSideBar'; 5 | import SideBarSection from './SideBarSection'; 6 | import SideBarBody from './SideBarBody'; 7 | 8 | interface MenuProps { 9 | visible: boolean; 10 | } 11 | 12 | const Menu: React.FunctionComponent = (props) => { 13 | const emitter = useMitt(); 14 | 15 | const { data: user, isLoading: userIsLoading, error: userError } = useUser(); 16 | 17 | const closeMenu = () => { 18 | emitter.emit('menuToggled'); 19 | }; 20 | 21 | return ( 22 | 23 |
    24 | 25 |
    26 | 27 | 28 |
    29 |
    30 | 31 | user:  32 | {userIsLoading && 'loading...'} 33 | {userError && JSON.stringify(userError)} 34 | {user && JSON.stringify(user)} 35 | 36 | 37 | Delete 38 | 39 |
    40 |
    41 | ); 42 | }; 43 | 44 | const styleClasses = { 45 | container: 'absolute z-10 w-full h-full flex flex-col justify-between', 46 | header: 'w-11/12 h-full flex justify-between items-center' 47 | }; 48 | 49 | export default Menu; 50 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/ScreenChanger.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { PreviewScreen, IconName } from '@/types'; 3 | import { useMitt } from '@/hooks'; 4 | import { Icon } from '@components/Decorators'; 5 | 6 | const ScreenChanger: React.FunctionComponent = () => { 7 | const emitter = useMitt(); 8 | const [screen, setScreen] = useState(PreviewScreen.DESKTOP); 9 | 10 | const iconNames: Record = { 11 | [PreviewScreen.DESKTOP]: 'desktop-screen', 12 | [PreviewScreen.MOBILE]: 'mobile-screen' 13 | }; 14 | 15 | const toggleScreen = () => { 16 | const newScreen = 17 | screen === PreviewScreen.DESKTOP ? PreviewScreen.MOBILE : PreviewScreen.DESKTOP; 18 | emitter.emit('previewScreenChange', newScreen); 19 | setScreen(newScreen); 20 | }; 21 | 22 | return ; 23 | }; 24 | 25 | export default ScreenChanger; 26 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@components/Decorators'; 2 | 3 | interface SideBarSearchBarProps { 4 | placeholder?: string; 5 | query: string; 6 | setQuery: (query: string) => void; 7 | } 8 | 9 | const SearchBar: React.FunctionComponent = (props) => { 10 | const handleSearchChange = (event: React.ChangeEvent) => { 11 | props.setQuery(event.target.value); 12 | }; 13 | 14 | const clearInput = () => { 15 | props.setQuery(''); 16 | }; 17 | 18 | return ( 19 |
    20 | 27 | {props.query.length > 0 ? ( 28 | 33 | ) : ( 34 | 35 | )} 36 |
    37 | ); 38 | }; 39 | 40 | const styleClasses = { 41 | container: 42 | 'flex items-center justify-start gap-2 w-11/12 h-12 mx-auto my-4 p-2 bg-white rounded-md', 43 | searchBarInput: 'w-full h-full px-2 focus:outline-none active:outline-none' 44 | }; 45 | 46 | export default SearchBar; 47 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/SideBarBody.tsx: -------------------------------------------------------------------------------- 1 | interface SideBarBodyProps { 2 | children: React.ReactNode | React.ReactNode[]; 3 | } 4 | 5 | const SideBarBody: React.FunctionComponent = ({ children }) => { 6 | return
    {children}
    ; 7 | }; 8 | 9 | const styleClasses = { 10 | container: 'w-full h-full flex flex-col justify-start' 11 | }; 12 | 13 | export default SideBarBody; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/SideBarSection.tsx: -------------------------------------------------------------------------------- 1 | interface SideBarSectionProps { 2 | position: 'top' | 'bottom'; 3 | children: React.ReactNode | React.ReactNode[]; 4 | } 5 | 6 | const SideBarSection: React.FunctionComponent = (props) => { 7 | const borderClass = props.position === 'top' ? 'border-b-4' : 'border-t-4'; 8 | 9 | return
    {props.children}
    ; 10 | }; 11 | 12 | const styleClasses = { 13 | container: 'flex justify-evenly items-center w-full h-20' 14 | }; 15 | 16 | export default SideBarSection; 17 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/SideBarTabTitle.tsx: -------------------------------------------------------------------------------- 1 | interface SideBarTabTitleProps { 2 | title: string; 3 | } 4 | 5 | const SideBarTabTitle: React.FunctionComponent = (props) => { 6 | return
    {props.title}
    ; 7 | }; 8 | 9 | const styleClasses = { 10 | container: 'w-11/12 mx-auto my-4 px-2' 11 | }; 12 | 13 | export default SideBarTabTitle; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/TabChooser.tsx: -------------------------------------------------------------------------------- 1 | interface TabChooserProps { 2 | onClick: () => void; 3 | children: React.ReactNode; 4 | } 5 | 6 | const TabChooser: React.FunctionComponent = ({ onClick, children }) => { 7 | return ( 8 |
    9 | {children} 10 |
    11 | ); 12 | }; 13 | 14 | const styleClasses = { 15 | container: 16 | 'flex-1 h-full flex cursor-pointer justify-center items-center border-0 border-blue-500' 17 | }; 18 | 19 | export default TabChooser; 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/SideBar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SideBarLeft } from './SideBarLeft'; 2 | export * from './SideBarLeft'; 3 | 4 | export { default as SideBarRight } from './SideBarRight'; 5 | export * from './SideBarRight'; 6 | 7 | export { default as Menu } from './Menu'; 8 | export * from './Menu'; 9 | -------------------------------------------------------------------------------- /apps/frontend/src/contexts/mitt.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { mitt, Emitter } from '@plugins/mitt'; 3 | import type { Events } from '@/types'; 4 | 5 | export type MittContextType = Emitter; 6 | 7 | export const emitter: MittContextType = mitt(); 8 | 9 | export const MittContext = createContext(emitter); 10 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './queries'; 2 | 3 | export * from './useContextMenu'; 4 | 5 | export * from './useMitt'; 6 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useLogin'; 2 | 3 | export * from './useUser'; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/queries/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { queryFactory, makeRequest } from './utils'; 2 | import type { FetcherArgs } from './utils'; 3 | 4 | type LoginBody = { 5 | email: string; 6 | password: string; 7 | }; 8 | 9 | export const useLogin = queryFactory('login', async ([body]: FetcherArgs) => { 10 | return await makeRequest({ 11 | url: '/auth/login', 12 | method: 'POST', 13 | body: body 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/queries/useUser.ts: -------------------------------------------------------------------------------- 1 | import { makeRequest, queryFactory } from './utils'; 2 | 3 | type User = Record; 4 | 5 | export const useUser = queryFactory('user', async () => { 6 | return await makeRequest({ 7 | url: '/users/me', 8 | method: 'GET' 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/queries/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './make-request'; 2 | 3 | export * from './query-factory'; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/queries/utils/query-factory.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import type { SWRConfiguration, RevalidatorOptions, SWRResponse } from 'swr'; 3 | 4 | const ERROR_CODES_WITH_NORMAL_RETRIES: number[] = [500]; 5 | const RETRIES_TIMEOUT = 2000; 6 | const LOWER_RETRIES_LIMIT: RevalidatorOptions['retryCount'] = 3; 7 | 8 | export type QueryArgs = Partial<{ 9 | body: TBody; 10 | params: TParams; 11 | }> & 12 | (TBody extends undefined 13 | ? TParams extends undefined 14 | ? undefined 15 | : { params: TParams } 16 | : TParams extends undefined 17 | ? { body: TBody } 18 | : { body: TBody; params: TParams }); 19 | 20 | export type FetcherArgs = [TBody, TParams]; 21 | 22 | type Query = (args?: QueryArgs) => SWRResponse; 23 | 24 | type Fetcher = (args: FetcherArgs) => Promise; 25 | 26 | const getFetcherArgs = ( 27 | args?: QueryArgs 28 | ): FetcherArgs => { 29 | if (args) { 30 | if (!args.body && args.params) { 31 | return [{} as TBody, args.params]; 32 | } 33 | 34 | if (args.body && !args.params) { 35 | return [args.body, {} as TParams]; 36 | } 37 | 38 | if (args.body && args.params) { 39 | return [args.body, args.params]; 40 | } 41 | } 42 | 43 | return [{} as TBody, {} as TParams]; 44 | }; 45 | 46 | const swrConfig: SWRConfiguration = { 47 | keepPreviousData: true, 48 | onErrorRetry: (error, key, config, revalidate, { retryCount }) => { 49 | if ( 50 | !ERROR_CODES_WITH_NORMAL_RETRIES.includes(error.status) && 51 | retryCount > LOWER_RETRIES_LIMIT 52 | ) { 53 | return; 54 | } 55 | 56 | if (ERROR_CODES_WITH_NORMAL_RETRIES.includes(error.status)) { 57 | setTimeout(() => revalidate({ retryCount }), RETRIES_TIMEOUT); 58 | } 59 | } 60 | }; 61 | 62 | export const queryFactory = ( 63 | key: string, 64 | fetcher: Fetcher 65 | ): Query => { 66 | return (args?: QueryArgs) => { 67 | const fetcherArgs = getFetcherArgs(args); 68 | return useSWR(key, () => fetcher(fetcherArgs), swrConfig); 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/useContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useDebugValue } from 'react'; 2 | import type { Dispatch, SetStateAction } from 'react'; 3 | import type { ContextMenuCoordinates } from '@/types'; 4 | 5 | export type ContextMenuHook = [ 6 | boolean, 7 | Dispatch>, 8 | ContextMenuCoordinates, 9 | Dispatch> 10 | ]; 11 | 12 | export const useContextMenu = (): ContextMenuHook => { 13 | // A boolean value to determine if the user has right-clicked 14 | const [clicked, setClicked] = useState(false); 15 | // Allows us to track the (x,y) coordinates of the users right click 16 | const [coords, setCoords] = useState({ x: 0, y: 0 }); 17 | 18 | useEffect(() => { 19 | // Reset clicked to false on user click 20 | const handleClick = () => { 21 | setClicked(false); 22 | }; 23 | 24 | // Add the listener for user click 25 | document.addEventListener('click', handleClick); 26 | 27 | // Clean up listener function to avoid memory leaks 28 | return () => { 29 | document.removeEventListener('click', handleClick); 30 | }; 31 | }, []); 32 | 33 | useDebugValue(clicked ? `Clicked at (${coords.x}, ${coords.y})` : 'Not clicked'); 34 | 35 | return [clicked, setClicked, coords, setCoords]; 36 | }; 37 | -------------------------------------------------------------------------------- /apps/frontend/src/hooks/useMitt.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useDebugValue } from 'react'; 2 | import { MittContext } from '@contexts/mitt'; 3 | import type { MittContextType } from '@contexts/mitt'; 4 | 5 | export const useMitt = (): MittContextType => { 6 | const context = useContext(MittContext); 7 | 8 | if (context === undefined) { 9 | throw new Error('useMitt must be used within a MittProvider'); 10 | } 11 | 12 | useDebugValue(context); 13 | 14 | return context; 15 | }; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | @apply m-0 p-0; 7 | } 8 | 9 | .icon { 10 | cursor: pointer; 11 | } 12 | 13 | @layer utilities { 14 | .full-right { 15 | @apply absolute top-0 right-0 w-full h-full px-1.5 text-xs cursor-pointer; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { RouterProvider } from 'react-router-dom'; 4 | import { Provider as StoreProvider } from 'react-redux'; 5 | import './main.css'; 6 | import 'react-tooltip/dist/react-tooltip.css'; 7 | import reportWebVitals from './reportWebVitals'; 8 | import Router from '@router/router'; 9 | import store from '@store/store'; 10 | 11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /apps/frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/router/router.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom'; 2 | import routes from './routes'; 3 | 4 | const Router = createBrowserRouter(routes); 5 | 6 | export default Router; 7 | -------------------------------------------------------------------------------- /apps/frontend/src/router/routes.tsx: -------------------------------------------------------------------------------- 1 | import type { RouteObject } from 'react-router-dom'; 2 | import App from '@/App'; 3 | import HomePage from '@views/home/HomePage'; 4 | import { Editor, EditorPage, Preview } from '@views/editor'; 5 | 6 | const routes: RouteObject[] = [ 7 | { 8 | path: '/', 9 | element: , 10 | children: [ 11 | { 12 | index: true, 13 | element: 14 | }, 15 | { 16 | path: 'editor', 17 | element: , 18 | children: [ 19 | { 20 | index: true, 21 | element: 22 | }, 23 | { 24 | path: 'preview', 25 | element: 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | ]; 32 | 33 | export default routes; 34 | -------------------------------------------------------------------------------- /apps/frontend/src/store/activeComponent/activeComponentSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import type { PayloadAction } from '@reduxjs/toolkit'; 3 | import type { ActiveComponent } from '@/types'; 4 | 5 | export interface ActiveComponentState { 6 | value: ActiveComponent; 7 | } 8 | 9 | const initialState: ActiveComponentState = { 10 | value: {} as ActiveComponent 11 | }; 12 | 13 | export const activeComponentSlice = createSlice({ 14 | name: 'activeComponent', 15 | initialState, 16 | reducers: { 17 | updateActiveComponent: (state, actions: PayloadAction) => { 18 | state.value = actions.payload; 19 | }, 20 | updateActiveComponentSpecs: (state, actions: PayloadAction) => { 21 | state.value.specs = actions.payload; 22 | }, 23 | resetActiveComponent: (state) => { 24 | state.value = {} as ActiveComponent; 25 | } 26 | } 27 | }); 28 | 29 | export const { updateActiveComponent, updateActiveComponentSpecs, resetActiveComponent } = 30 | activeComponentSlice.actions; 31 | 32 | export const selectActiveComponent = (state: { activeComponent: { value: ActiveComponent } }) => 33 | state.activeComponent.value; 34 | 35 | const activeComponentReducer = activeComponentSlice.reducer; 36 | 37 | export default activeComponentReducer; 38 | -------------------------------------------------------------------------------- /apps/frontend/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, ThunkAction, Actions } from '@reduxjs/toolkit'; 2 | import previewTreeReducer from './previewTree/previewTreeSlice'; 3 | import activeComponentReducer from './activeComponent/activeComponentSlice'; 4 | 5 | const store = configureStore({ 6 | reducer: { 7 | previewTree: previewTreeReducer, 8 | activeComponent: activeComponentReducer 9 | } 10 | }); 11 | 12 | export type AppDispatch = typeof store.dispatch; 13 | 14 | // F**king typescript (╯°□°)╯︵ ┻━┻ 15 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 16 | // @ts-ignore 17 | export type RootState = ReturnType; 18 | 19 | export default store; 20 | 21 | export type AppThunk = ThunkAction< 22 | ReturnType, 23 | RootState, 24 | unknown, 25 | Actions 26 | >; 27 | -------------------------------------------------------------------------------- /apps/frontend/src/types/active-component.type.ts: -------------------------------------------------------------------------------- 1 | import type { PreviewElementData } from './tree.type'; 2 | 3 | export interface ActiveComponent extends Pick { 4 | index: number; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/types/context-menu.type.ts: -------------------------------------------------------------------------------- 1 | import type { IconName } from './icons.type'; 2 | 3 | export type ContextMenuAction = { 4 | icon?: IconName; 5 | label: string; 6 | handler: (e?: React.MouseEvent) => void; 7 | }; 8 | 9 | export type ContextMenuCoordinates = { 10 | x: number; 11 | y: number; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/frontend/src/types/events.type.ts: -------------------------------------------------------------------------------- 1 | import type { ActiveComponent } from './active-component.type'; 2 | import type { PreviewScreen } from './preview.type'; 3 | import type { UpdateElementData, MoveElementData } from './tree.type'; 4 | 5 | export type Events = { 6 | dragStartEvent: undefined; 7 | componentSelected: ActiveComponent; 8 | componentPropertyChanged: UpdateElementData; 9 | componentMoved: MoveElementData; 10 | itemInterfaceClicked: number | null; 11 | dragEndEvent: undefined; 12 | previewScreenChange: PreviewScreen; 13 | previewRefresh: undefined; 14 | menuToggled: undefined; 15 | }; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/types/icons.type.ts: -------------------------------------------------------------------------------- 1 | export type IconName = 2 | | 'add-circle-line' 3 | | 'arrow-small-down' 4 | | 'arrow-small-up' 5 | | 'bars' 6 | | 'chevron-left' 7 | | 'cross-mark' 8 | | 'cubes' 9 | | 'desktop-screen' 10 | | 'grip-vertical' 11 | | 'information-circle' 12 | | 'minus' 13 | | 'mobile-screen' 14 | | 'option' 15 | | 'refresh-arrows' 16 | | 'save' 17 | | 'search' 18 | | 'stack' 19 | | 'user' 20 | | 'ui-button-play' 21 | | 'ui-default' 22 | | 'ui-eject' 23 | | 'ui-table-columns' 24 | | 'ui-toggle-off'; 25 | -------------------------------------------------------------------------------- /apps/frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './active-component.type'; 2 | 3 | export * from './context-menu.type'; 4 | 5 | export * from './events.type'; 6 | 7 | export * from './icons.type'; 8 | 9 | export * from './preview.type'; 10 | 11 | export * from './property.type'; 12 | 13 | export * from './tooltip.type'; 14 | 15 | export * from './tree.type'; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/types/preview.type.ts: -------------------------------------------------------------------------------- 1 | export enum PreviewScreen { 2 | MOBILE = 'mobile', 3 | DESKTOP = 'desktop' 4 | } 5 | 6 | export enum Tabs { 7 | COMPONENTS, 8 | REORGANIZE 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/types/property.type.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentProp, Control } from '@react-site-editor/types'; 2 | 3 | export interface PropertyProps> extends Omit, 'control'> { 4 | name: string; 5 | value: T; 6 | spec: U; 7 | onChange: (payload: T, event?: Event) => void; 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/types/tooltip.type.ts: -------------------------------------------------------------------------------- 1 | export type TooltipPlace = 'top' | 'bottom' | 'left' | 'right'; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/types/tree.type.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos } from '@react-site-editor/types'; 2 | 3 | export interface PreviewElement { 4 | index: number; 5 | data: PreviewElementData; 6 | } 7 | 8 | export interface PreviewElementData extends ComponentInfos { 9 | children?: Omit[]; 10 | } 11 | 12 | export interface UpdateElementData { 13 | id: number; 14 | propName: string; 15 | value: unknown; 16 | } 17 | 18 | export interface MoveElementData { 19 | currentIndex: number; 20 | newIndex: number; 21 | } 22 | 23 | export type PreviewTree = PreviewElementData[]; 24 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './remove-non-serializable'; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/remove-non-serializable.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos, ComponentProp, ControlType } from '@react-site-editor/types'; 2 | 3 | export type Specs = ComponentInfos['specs']; 4 | 5 | const nonSerializableControls = ['callback'] as ControlType[]; 6 | 7 | const isComponentProp = (object: unknown): object is ComponentProp => { 8 | return 'control' in Object(object); 9 | }; 10 | 11 | const isSerializableComponentProp = (object: unknown): boolean => { 12 | return isComponentProp(object) && !nonSerializableControls.includes(object.control.type); 13 | }; 14 | 15 | export function removeNonSerializable(specs: Specs): [Specs, Specs] { 16 | const serializable = {} as Specs; 17 | const nonSerializable = {} as Specs; 18 | 19 | Object.entries(specs).forEach(([key, value]) => { 20 | if (isSerializableComponentProp(value)) { 21 | serializable[key] = value as Specs[typeof key]; 22 | return; 23 | } 24 | 25 | nonSerializable[key] = value as Specs[typeof key]; 26 | }); 27 | 28 | return [serializable, nonSerializable]; 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/src/views/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMitt } from '@/hooks'; 3 | import { Menu, SideBarLeft, SideBarRight } from '@components/SideBar'; 4 | import { Preview } from '@components/Preview'; 5 | 6 | const Editor: React.FunctionComponent = () => { 7 | const emitter = useMitt(); 8 | const [isMenuVisible, setIsMenuVisible] = useState(false); 9 | 10 | emitter.on('menuToggled', () => { 11 | setIsMenuVisible(!isMenuVisible); 12 | }); 13 | 14 | return ( 15 |
    16 | 17 | 18 | 19 | 20 |
    21 | ); 22 | }; 23 | 24 | const styleClasses = { 25 | container: 'relative flex w-screen h-screen overflow-hidden' 26 | }; 27 | 28 | export default Editor; 29 | -------------------------------------------------------------------------------- /apps/frontend/src/views/editor/EditorPage.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | 3 | const EditorPage: React.FunctionComponent = () => { 4 | return ; 5 | }; 6 | 7 | export default EditorPage; 8 | -------------------------------------------------------------------------------- /apps/frontend/src/views/editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './Editor'; 2 | export * from './Editor'; 3 | 4 | export { default as EditorPage } from './EditorPage'; 5 | export * from './EditorPage'; 6 | 7 | export { default as Preview } from './Preview'; 8 | export * from './Preview'; 9 | -------------------------------------------------------------------------------- /apps/frontend/src/views/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | const HomePage: React.FunctionComponent = () => { 4 | return ( 5 |
    6 | 7 | 8 | 9 |
    10 | ); 11 | }; 12 | 13 | const styleClasses = { 14 | container: 'flex justify-center items-center w-screen h-fit min-h-screen' 15 | }; 16 | 17 | export default HomePage; 18 | -------------------------------------------------------------------------------- /apps/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module 'envConfig'; 3 | 4 | interface ImportMetaEnv { 5 | readonly APP_NODE_ENV: 'development' | 'production' | 'test'; 6 | readonly APP_PORT: string; 7 | readonly APP_API_URL: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | 'functional-grey': '#64748b' 10 | }, 11 | gridTemplateColumns: { 12 | 'auto-fit': 'repeat(auto-fit, minmax(6rem, 1fr))' 13 | } 14 | } 15 | }, 16 | plugins: [ 17 | plugin(function ({ addVariant }) { 18 | addVariant('number-spin', '&::-webkit-inner-spin-button, &::-webkit-outer-spin-button'); 19 | }) 20 | ] 21 | }; 22 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"], 8 | "@assets/*": ["./src/assets/*"], 9 | "@components/*": ["./src/components/*"], 10 | "@contexts/*": ["./src/contexts/*"], 11 | "@plugins/*": ["./src/plugins/*"], 12 | "@router/*": ["./src/router/*"], 13 | "@store/*": ["./src/store/*"], 14 | "@views/*": ["./src/views/*"] 15 | } 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite'; 2 | import type { UserConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import { resolve } from 'path'; 5 | 6 | export default ({ mode }: UserConfig) => { 7 | if (!mode) { 8 | throw new Error('App running mode is undefined'); 9 | } 10 | 11 | process.env = Object.assign(process.env, loadEnv(mode, process.cwd(), '')); 12 | 13 | return defineConfig({ 14 | envPrefix: 'APP_', 15 | server: { 16 | port: parseInt(process.env.APP_PORT ?? '3000') 17 | }, 18 | plugins: [react()], 19 | build: { 20 | target: 'esnext', 21 | outDir: './dist' 22 | }, 23 | resolve: { 24 | alias: [ 25 | { 26 | find: '@', 27 | replacement: resolve(__dirname, '/src') 28 | }, 29 | { 30 | find: '@assets', 31 | replacement: resolve(__dirname, '/src/assets') 32 | }, 33 | { 34 | find: '@components', 35 | replacement: resolve(__dirname, '/src/components') 36 | }, 37 | { 38 | find: '@contexts', 39 | replacement: resolve(__dirname, '/src/contexts') 40 | }, 41 | { 42 | find: '@plugins', 43 | replacement: resolve(__dirname, '/src/plugins') 44 | }, 45 | { 46 | find: '@router', 47 | replacement: resolve(__dirname, '/src/router') 48 | }, 49 | { 50 | find: '@store', 51 | replacement: resolve(__dirname, '/src/store') 52 | }, 53 | { 54 | find: '@views', 55 | replacement: resolve(__dirname, '/src/views') 56 | } 57 | ] 58 | } 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-site-editor", 3 | "version": "0.0.1", 4 | "private": true, 5 | "author": "Frelya", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=16.19.0" 9 | }, 10 | "scripts": { 11 | "preinstall": "npx only-allow pnpm", 12 | "dev": "npm-run-all --parallel backend:dev frontend:dev", 13 | "start": "npm-run-all --parallel backend:start frontend:start", 14 | "build": "turbo run build", 15 | "test": "turbo run test", 16 | "backend": "pnpm --filter backend", 17 | "backend:build": "pnpm backend build", 18 | "backend:dev": "pnpm backend dev", 19 | "backend:start": "pnpm backend start", 20 | "backend:docs": "pnpm backend db:generate", 21 | "frontend": "pnpm --filter frontend", 22 | "frontend:build": "pnpm frontend build", 23 | "frontend:dev": "pnpm frontend dev", 24 | "frontend:start": "pnpm frontend start", 25 | "frontend:icons": "pnpm frontend icons", 26 | "frontend:preview": "pnpm frontend preview", 27 | "frontend:scripts:init": "pnpm frontend scripts:init", 28 | "ui": "pnpm --filter ui", 29 | "ui:build": "pnpm ui build", 30 | "ui:components": "pnpm ui storybook:dev", 31 | "ui:components:create": "pnpm ui components:create", 32 | "ui:components:expose": "pnpm ui components:expose", 33 | "ui:dev": "pnpm ui dev", 34 | "ui:docs": "pnpm ui storybook:dev:docs", 35 | "ui:preview": "pnpm ui preview", 36 | "ui:scripts:init": "pnpm ui scripts:init", 37 | "ui:start": "pnpm ui start", 38 | "lint": "npx eslint --cache --ext .js,.ts,.jsx,.tsx,.json .", 39 | "lint:fix": "pnpm lint --fix", 40 | "format": "npx prettier --cache --write ./ '!**/*.{js,ts,jsx,tsx,html,json}'", 41 | "ci:style": "pnpm lint:fix && pnpm format", 42 | "ci:all": "pnpm ci:style && pnpm test", 43 | "push": "pnpm ci:all && git commit -am 'ci: lint and format' && git push" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "^18.16.14", 47 | "@types/prettier": "^2.7.2", 48 | "@typescript-eslint/eslint-plugin": "^5.59.7", 49 | "@typescript-eslint/parser": "^5.59.7", 50 | "eslint": "^8.41.0", 51 | "eslint-config-prettier": "^8.8.0", 52 | "eslint-plugin-prettier": "^4.2.1", 53 | "eslint-plugin-react": "^7.32.2", 54 | "npm-run-all": "^4.1.5", 55 | "prettier": "2.8.3", 56 | "turbo": "^1.9.8" 57 | }, 58 | "packageManager": "pnpm@8.5.1", 59 | "dependencies": { 60 | "dotenv": "^16.3.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/functions/README.md: -------------------------------------------------------------------------------- 1 | # React-Site-Editor - Functions 2 | 3 | ## Description 4 | -------------------------------------------------------------------------------- /packages/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/stringutils'; 2 | 3 | export * from './src/others'; 4 | 5 | export * from './src/parsers'; 6 | 7 | // TODO: move definitions below into dedicated files if possible 8 | 9 | export const config = { 10 | prefix: '__reb' 11 | }; 12 | 13 | export function setPrefix(value: string) { 14 | config.prefix = ['__', value].join(''); 15 | } 16 | 17 | export function prefix(value: string) { 18 | return [config.prefix, value].join('__'); 19 | } 20 | -------------------------------------------------------------------------------- /packages/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-site-editor/functions", 3 | "version": "0.0.1", 4 | "description": "Utility functions for react-site-editor", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\"" 8 | }, 9 | "dependencies": { 10 | "@react-site-editor/types": "^0.0.1" 11 | }, 12 | "author": "Frelya", 13 | "license": "MIT" 14 | } 15 | -------------------------------------------------------------------------------- /packages/functions/src/others/arrayToGridFlowTemplate.ts: -------------------------------------------------------------------------------- 1 | export function arrayToGridFlowTemplate(array: number[]) { 2 | return array.map((item) => `${item}fr`).join(' '); 3 | } 4 | -------------------------------------------------------------------------------- /packages/functions/src/others/file2base64.ts: -------------------------------------------------------------------------------- 1 | export function file2base64(file: File): Promise { 2 | return new Promise((res) => { 3 | const fr = new FileReader(); 4 | fr.readAsDataURL(file); 5 | fr.onloadend = () => res(fr.result as string); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /packages/functions/src/others/findCombinations.ts: -------------------------------------------------------------------------------- 1 | // This function returns all possible combinations of n numbers from 1 to m so that the sum of the numbers is m. 2 | export function findCombinations(n: number, m: number): number[][] { 3 | if (n === 1 && m >= 1) { 4 | return [[m]]; 5 | } 6 | 7 | const result: number[][] = []; 8 | 9 | for (let i = 1; i <= m; i++) { 10 | const combinations = findCombinations(n - 1, m - i); 11 | 12 | combinations.forEach((combination) => { 13 | result.push([i, ...combination]); 14 | }); 15 | } 16 | 17 | return result; 18 | } 19 | -------------------------------------------------------------------------------- /packages/functions/src/others/gridFlowTemplateToArray.ts: -------------------------------------------------------------------------------- 1 | export function gridFlowTemplateToArray(template: string) { 2 | const pattern = /(\d+)fr/g; 3 | const matches = template.match(pattern); 4 | return matches ? matches.map((item) => parseInt(item, 10)) : []; 5 | } 6 | -------------------------------------------------------------------------------- /packages/functions/src/others/index.ts: -------------------------------------------------------------------------------- 1 | export { arrayToGridFlowTemplate } from './arrayToGridFlowTemplate'; 2 | 3 | export { findCombinations } from './findCombinations'; 4 | 5 | export { gridFlowTemplateToArray } from './gridFlowTemplateToArray'; 6 | 7 | export { innerContentOfHtmlDiv } from './innerContentOfHtmlDiv'; 8 | 9 | export { file2base64 } from './file2base64'; 10 | -------------------------------------------------------------------------------- /packages/functions/src/others/innerContentOfHtmlDiv.ts: -------------------------------------------------------------------------------- 1 | const regexMatcher = /(.*?)<\/div>/; 2 | 3 | export function innerContentOfHtmlDiv(html: string): string { 4 | const matchedElement = html.match(regexMatcher); 5 | return matchedElement ? matchedElement[1] : ''; 6 | } 7 | -------------------------------------------------------------------------------- /packages/functions/src/parsers/index.ts: -------------------------------------------------------------------------------- 1 | export { specsValuesParser } from './specsValuesParser'; 2 | -------------------------------------------------------------------------------- /packages/functions/src/parsers/specsValuesParser.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsSpecs, InferredProps } from '@react-site-editor/types'; 2 | 3 | export function specsValuesParser(specs: ComponentPropsSpecs) { 4 | const props = {} as InferredProps; 5 | let key: keyof ComponentPropsSpecs; 6 | 7 | for (key in specs) { 8 | const spec = specs[key]; 9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 10 | // @ts-ignore 11 | props[key] = spec.value ? spec.value : spec; 12 | } 13 | 14 | return props; 15 | } 16 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str: string): string { 2 | return [str[0].toUpperCase(), str.substring(1)].join(''); 3 | } 4 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/index.ts: -------------------------------------------------------------------------------- 1 | export { capitalize } from './capitalize'; 2 | 3 | export { kebabToPascal } from './kebabToPascal'; 4 | 5 | export { kebabToSnake } from './kebabToSnake'; 6 | 7 | export { pascalToKebab } from './pascalToKebab'; 8 | 9 | export { pascalToSpaced } from './pascalToSpaced'; 10 | 11 | export { pascalToSnake } from './pascalToSnake'; 12 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/kebabToPascal.ts: -------------------------------------------------------------------------------- 1 | export function kebabToPascal(str: string): string { 2 | return str 3 | .split('-') 4 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 5 | .join(''); 6 | } 7 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/kebabToSnake.ts: -------------------------------------------------------------------------------- 1 | export function kebabToSnake(str: string): string { 2 | const pattern = /-/g; 3 | return str.replace(pattern, '_'); 4 | } 5 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/pascalToKebab.ts: -------------------------------------------------------------------------------- 1 | export function pascalToKebab(s: string) { 2 | const pattern = /\.?([A-Z]+[a-z]*)/g; 3 | return s.replace(pattern, function (substring, ...args) { 4 | substring = substring.toLowerCase(); 5 | if (args[1] > 0) { 6 | substring = '-' + substring; 7 | } 8 | return substring; 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/pascalToSnake.ts: -------------------------------------------------------------------------------- 1 | export function pascalToSnake(s: string) { 2 | const pattern = /\.?([A-Z]+[a-z]*)/g; 3 | return s.replace(pattern, function (substring, ...args) { 4 | substring = substring.toLowerCase(); 5 | if (args[1] > 0) { 6 | substring = '_' + substring; 7 | } 8 | return substring; 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/functions/src/stringutils/pascalToSpaced.ts: -------------------------------------------------------------------------------- 1 | export function pascalToSpaced(s: string) { 2 | const pattern = /([A-Z0-9])/g; 3 | return s.replace(pattern, ' $1').trim(); 4 | } 5 | -------------------------------------------------------------------------------- /packages/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | } 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/types/README.md: -------------------------------------------------------------------------------- 1 | # React-Site-Editor - Types 2 | 3 | ## Description 4 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-site-editor/types", 3 | "version": "0.0.1", 4 | "description": "Types for react-site-editor packages", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\"" 8 | }, 9 | "author": "Frelya", 10 | "license": "MIT" 11 | } 12 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | } 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/ui/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import * as process from 'process'; 3 | 4 | const storiesFilesPath = (): string => { 5 | const flagIndex = process.argv.indexOf('--component'); 6 | const flagReturn = flagIndex !== -1 && process.argv[flagIndex + 1]; 7 | 8 | return flagReturn 9 | ? `**/*/${process.argv[flagIndex + 1]}.stories.tsx` 10 | : '**/*/*.stories.tsx'; 11 | } 12 | 13 | const config: StorybookConfig = { 14 | stories: [{ 15 | titlePrefix: 'UI Components', 16 | directory: '../src/components/exposed', 17 | files: storiesFilesPath() 18 | }], 19 | addons: [ 20 | '@storybook/addon-links', 21 | '@storybook/addon-essentials', 22 | '@storybook/addon-interactions', 23 | ], 24 | framework: { 25 | name: '@storybook/react-vite', 26 | options: { 27 | strictMode: true 28 | } 29 | }, 30 | docs: { 31 | autodocs: true, 32 | defaultName: 'Documentation' 33 | }, 34 | core: { 35 | disableTelemetry: true, 36 | builder: '@storybook/builder-vite' 37 | } 38 | }; 39 | 40 | export default config; 41 | -------------------------------------------------------------------------------- /packages/ui/.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | 3 | addons.setConfig({ 4 | panelPosition: 'right', 5 | }); 6 | -------------------------------------------------------------------------------- /packages/ui/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import '../src/main.css'; 2 | import type { Preview } from '@storybook/react'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | actions: { argTypesRegex: '^on.*' }, 7 | options: { 8 | storySort: { 9 | method: 'alphabetical', 10 | locales: 'en-US' 11 | } 12 | }, 13 | layout: 'centered', 14 | docs: { 15 | source: { 16 | language: 'tsx', 17 | dark: true 18 | } 19 | }, 20 | loaders: [ 21 | // TODO: Add loaders to help waiting 22 | ] 23 | } 24 | }; 25 | 26 | export default preview; 27 | -------------------------------------------------------------------------------- /packages/ui/README.md: -------------------------------------------------------------------------------- 1 | # React-Site-Editor - UI Components 2 | 3 | ## Description 4 | -------------------------------------------------------------------------------- /packages/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Site Editor 9 | 10 | 11 | 12 |
    13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/ui/libs/index.ts: -------------------------------------------------------------------------------- 1 | export { prettier } from './prettier'; 2 | -------------------------------------------------------------------------------- /packages/ui/libs/prettier.ts: -------------------------------------------------------------------------------- 1 | import { Options, format } from 'prettier'; 2 | 3 | const prettierOptions: Options = { 4 | bracketSameLine: true, 5 | printWidth: 100, 6 | proseWrap: 'always', 7 | semi: true, 8 | singleQuote: true, 9 | tabWidth: 4, 10 | trailingComma: 'none', 11 | useTabs: false, 12 | parser: 'typescript' 13 | }; 14 | 15 | export function prettier(str: string, options: Options = {}) { 16 | return format(str, { ...prettierOptions, ...options }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-site-editor/ui", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "./src/index.ts", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "start": "pnpm build && vite", 12 | "scripts:init": "find ./scripts/ -type f -iname \"*.sh\" -exec chmod +x {} \\;", 13 | "components:expose": "pnpm scripts:init && ./scripts/expose-components.sh", 14 | "components:create": "pnpm scripts:init && ./scripts/create-component.sh", 15 | "storybook:dev": "storybook dev -p 6006", 16 | "storybook:dev:docs": "pnpm storybook:dev --docs", 17 | "storybook:dev:comp": "pnpm storybook:dev -- --component", 18 | "storybook:dev:docs:comp": "pnpm storybook:dev:docs -- --component", 19 | "storybook:build": "storybook build" 20 | }, 21 | "dependencies": { 22 | "@react-site-editor/functions": "0.0.1", 23 | "@react-site-editor/types": "0.0.1", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@storybook/addon-essentials": "^7.4.5", 29 | "@storybook/addon-interactions": "^7.4.5", 30 | "@storybook/addon-links": "^7.4.5", 31 | "@storybook/blocks": "^7.4.5", 32 | "@storybook/builder-vite": "^7.4.5", 33 | "@storybook/manager-api": "^7.4.5", 34 | "@storybook/react": "^7.4.5", 35 | "@storybook/react-vite": "^7.4.5", 36 | "@storybook/types": "^7.4.5", 37 | "@types/glob": "^8.1.0", 38 | "@types/react": "^18.0.27", 39 | "@types/react-dom": "^18.0.10", 40 | "@vitejs/plugin-react": "^3.1.0", 41 | "autoprefixer": "^10.4.13", 42 | "esbuild": "^0.17.8", 43 | "fast-glob": "^3.2.11", 44 | "postcss": "^8.4.21", 45 | "prettier": "2.8.7", 46 | "prop-types": "^15.8.1", 47 | "storybook": "^7.4.5", 48 | "tailwindcss": "^3.2.6", 49 | "typescript": "^4.9.3", 50 | "vite": "^4.4.9" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/ui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /packages/ui/scripts/create-component.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage="Usage: pnpm components:create [OPTIONS]... 4 | Shorthand to create a new component in the project 5 | 6 | Options: 7 | --name The name of the component 8 | --category The category of the component 9 | --help Display this help and exit 10 | " 11 | 12 | component_name="" 13 | category_name="" 14 | 15 | if [ "$(pwd | awk -F/ '{print $NF}')" != "ui" ] 16 | then 17 | echo -e "\033[31mError: Please run this script from the 'packages/ui' folder or the project root directory\033[39m" 18 | exit 1 19 | fi 20 | 21 | # Getting the component name and category name 22 | while [ $# -gt 0 ]; do 23 | case "$1" in 24 | --name=*) 25 | component_name="${1#*=}" 26 | ;; 27 | --category=*) 28 | category_name="${1#*=}" 29 | ;; 30 | --help|-h) 31 | echo -e "\033[32m${usage}\033[39m" 32 | exit 1 33 | ;; 34 | *) 35 | echo -e "\033[31mError: Unknown argument: $1\033[39m \n" 36 | exit 1 37 | esac 38 | shift 39 | done 40 | 41 | if [ -z "$component_name" ] || [ -z "$category_name" ] 42 | then 43 | echo -e "\033[31mError: Missing arguments\033[39m \n" 44 | echo -e "${usage}\n" 45 | exit 1 46 | fi 47 | 48 | base_dir=. 49 | inter_file=${base_dir}/scripts/output 50 | script_file=${base_dir}/scripts/modules/createComponent.ts 51 | 52 | required_file=${base_dir}/libs/prettier.ts 53 | 54 | echo -e "[ ] Setting up..." 55 | 56 | # Generate a single typescript file containing the prettier function 57 | 58 | # Step1: add the prettier function 59 | cat $required_file > ${inter_file}.ts 60 | 61 | # Step2: add a blank line 62 | echo >> ${inter_file}.ts 63 | 64 | # Step3: add the createComponent script, by removing the import of 65 | # prettier at the top of the file 66 | tail -n +2 $script_file >> ${inter_file}.ts 67 | 68 | # transpile and rename 69 | tsc ${inter_file}.ts && cp ${inter_file}.js ${inter_file}.cjs 70 | 71 | # run... 72 | if node ${inter_file}.cjs "${component_name}" "${category_name}"; 73 | then 74 | echo -e "\033[32m\nComponent ${component_name} created successfully\n\033[39m" 75 | # clean up 76 | rm ${inter_file}* 77 | else 78 | echo -e "\033[31m\nError: Component creation failed\n\033[39m" 79 | # clean up anyway and exit with error 80 | rm ${inter_file}* && exit 1 81 | fi 82 | -------------------------------------------------------------------------------- /packages/ui/scripts/expose-components.sh: -------------------------------------------------------------------------------- 1 | if [ "$(pwd | awk -F/ '{print $NF}')" != "ui" ] 2 | then 3 | echo -e "\033[31mError: Please run this script from the 'packages/ui' folder or the project root directory\033[39m" 4 | exit 1 5 | fi 6 | 7 | base_dir=. 8 | inter_file=${base_dir}/scripts/output 9 | script_file=${base_dir}/scripts/modules/exposeComponents.ts 10 | 11 | required_file=${base_dir}/libs/prettier.ts 12 | 13 | # Generate a single typescript file containing the prettier function 14 | 15 | # Step1: add the prettier function 16 | cat $required_file > ${inter_file}.ts 17 | 18 | # Step2: add a blank line 19 | echo >> ${inter_file}.ts 20 | 21 | # Step3: add the createComponent script, by removing the import of 22 | # prettier at the top of the file 23 | tail -n +2 $script_file >> ${inter_file}.ts 24 | 25 | # transpile and rename 26 | tsc ${inter_file}.ts && cp ${inter_file}.js ${inter_file}.cjs 27 | 28 | # run.. 29 | if node ${inter_file}.cjs; 30 | then 31 | printf "\033[32m\nComponents exposed successfully\n\033[39m" 32 | # clean up 33 | rm ${inter_file}* 34 | else 35 | printf "\033[31m\nError: Components exposition failed\n\033[39m" 36 | # clean up anyway and exit with error 37 | rm ${inter_file}* && exit 1 38 | fi 39 | -------------------------------------------------------------------------------- /packages/ui/scripts/modules/exposeComponents.ts: -------------------------------------------------------------------------------- 1 | import { prettier } from '../../libs'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | 5 | // Get all subdirectories of a directory 6 | function getSubdirectories(dirPath: string): string[] { 7 | return fs 8 | .readdirSync(dirPath) 9 | .filter((file) => fs.statSync(path.join(dirPath, file)).isDirectory()); 10 | } 11 | 12 | const baseDir = 'src/components/exposed'; 13 | const componentFiles: string[] = []; 14 | 15 | const categoriesDir = getSubdirectories(path.resolve(baseDir)); 16 | 17 | for (const componentDir of categoriesDir) { 18 | const categoryDirPath = path.join(baseDir, componentDir); 19 | const componentsDir = getSubdirectories(categoryDirPath); 20 | 21 | fs.writeFileSync( 22 | path.join(categoryDirPath, 'index.ts'), 23 | componentsDir 24 | .map((name) => 25 | prettier( 26 | `export { default as ${name} } from './${name}'; 27 | export * from './${name}';` 28 | ) 29 | ) 30 | .join('\n') 31 | ); 32 | 33 | for (const component of componentsDir) { 34 | fs.writeFileSync( 35 | `${categoryDirPath}/${component}/index.ts`, 36 | prettier( 37 | `export { default } from './${component}'; 38 | export { defaultProps as ${ 39 | component.charAt(0).toLowerCase() + component.slice(1) 40 | }DefaultProps } from './${component}'; 41 | export * from './${component}.types';` 42 | ) 43 | ); 44 | } 45 | 46 | componentFiles.push(componentDir + '/' + componentDir); 47 | } 48 | 49 | const componentNames = componentFiles.map((file) => path.basename(file, '.tsx')); 50 | 51 | const indexFile = path.join(__dirname, '../src/index.ts'); 52 | 53 | fs.writeFileSync( 54 | indexFile, 55 | componentNames 56 | .map((name) => prettier(`export * from './components/exposed/${name}';`)) 57 | .join('\n') + 58 | '\n' + 59 | prettier(`export * from './components';`) 60 | ); 61 | 62 | console.log(`Exported ${componentNames.length} categories of components to ${indexFile}`); 63 | -------------------------------------------------------------------------------- /packages/ui/scripts/modules/rewrite-styles.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as glob from 'glob'; 4 | 5 | const directoryPath = 'src/components/exposed'; // Remplacez par le chemin de votre projet 6 | 7 | // Recherche tous les fichiers .module.css dans le projet 8 | const cssFiles = glob.sync(`${directoryPath}/**/*.module.css`); 9 | 10 | // Parcourir chaque fichier CSS 11 | cssFiles.forEach((cssFilePath: string) => { 12 | // Ajouter chaque classe au fichier d'export local 13 | const outputFileName = `${path.basename(cssFilePath)}`; 14 | 15 | console.log('Création du fichier', outputFileName); 16 | 17 | fs.copyFileSync(cssFilePath, path.join(__dirname, '../../dist', cssFilePath.substring(4))); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import reactLogo from './assets/react.svg'; 2 | 3 | function App() { 4 | return ( 5 |
    6 | 7 | React logo 8 | 9 |
    10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button/Button.component.tsx: -------------------------------------------------------------------------------- 1 | import { specsValuesParser } from '@react-site-editor/functions'; 2 | import type { ComponentPropsSpecs } from '@react-site-editor/types'; 3 | import type { ButtonProps } from './Button.types'; 4 | import styles from './Button.module.css'; 5 | 6 | const Button: React.FunctionComponent = (props) => { 7 | const textColor = props.textColor; 8 | const fontSize = `font${props.fontSize}` as keyof typeof styles; 9 | const backgroundColor = props.backgroundColor; 10 | 11 | const handleClick = (event: React.MouseEvent) => { 12 | event.preventDefault(); 13 | console.log('Button clicked'); 14 | }; 15 | 16 | return ( 17 | 26 | ); 27 | }; 28 | 29 | export const propsSpecs: ComponentPropsSpecs = { 30 | text: { 31 | value: 'Button', 32 | control: { 33 | type: 'text' 34 | } 35 | }, 36 | textColor: { 37 | value: '#ffffff', 38 | control: { 39 | type: 'color' 40 | } 41 | }, 42 | fontSize: { 43 | value: 1, 44 | control: { 45 | type: 'select', 46 | options: [1, 2, 3] 47 | } 48 | }, 49 | backgroundColor: { 50 | value: '#3b82f6', 51 | control: { 52 | type: 'color' 53 | } 54 | }, 55 | iconName: 'ui-button-play' 56 | }; 57 | 58 | Button.defaultProps = specsValuesParser(propsSpecs); 59 | 60 | export default Button; 61 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .baseButton { 2 | @apply font-bold py-2 px-4 rounded; 3 | } 4 | 5 | .font1 { 6 | @apply text-xs; 7 | } 8 | 9 | .font2 { 10 | @apply text-sm; 11 | } 12 | 13 | .font3 { 14 | @apply text-base; 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { specsValuesParser } from '@react-site-editor/functions'; 3 | import { argTypesControlsParser } from '@/utils'; 4 | import Button, { propsSpecs } from './Button.component'; 5 | import type { ButtonProps } from './Button.types'; 6 | 7 | const meta = { 8 | title: 'Buttons/Button', 9 | // Get the controls from the specs 10 | argTypes: argTypesControlsParser(propsSpecs), 11 | component: Button 12 | } satisfies Meta; 13 | 14 | type ButtonStory = StoryObj; 15 | 16 | export const Default = { 17 | // Get the default props from the specs 18 | args: specsValuesParser(propsSpecs) 19 | } satisfies ButtonStory; 20 | 21 | export default meta; 22 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button/Button.types.ts: -------------------------------------------------------------------------------- 1 | export interface ButtonProps { 2 | text: string; 3 | textColor: string; 4 | fontSize: number; 5 | backgroundColor: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button.component'; 2 | export { propsSpecs as buttonPropsSpecs } from './Button.component'; 3 | export * from './Button.types'; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button2/Button2.component.tsx: -------------------------------------------------------------------------------- 1 | import { specsValuesParser } from '@react-site-editor/functions'; 2 | import type { ComponentPropsSpecs } from '@react-site-editor/types'; 3 | import type { Button2Props } from './Button2.types'; 4 | import styles from './Button2.module.css'; 5 | 6 | const Button2: React.FunctionComponent = (props) => { 7 | const fontSize = `font${props.fontSize}` as keyof typeof styles; 8 | 9 | const handleClick = (event: React.MouseEvent) => { 10 | event.preventDefault(); 11 | console.log('Button 2 clicked'); 12 | }; 13 | 14 | return ( 15 | 18 | ); 19 | }; 20 | 21 | export const propsSpecs: ComponentPropsSpecs = { 22 | text: { 23 | value: 'Button2', 24 | control: { 25 | type: 'text' 26 | } 27 | }, 28 | fontSize: { 29 | value: 1, 30 | control: { 31 | type: 'select', 32 | options: [1, 2, 3] 33 | } 34 | }, 35 | iconName: 'ui-toggle-off' 36 | }; 37 | 38 | Button2.defaultProps = specsValuesParser(propsSpecs); 39 | 40 | export default Button2; 41 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button2/Button2.module.css: -------------------------------------------------------------------------------- 1 | .baseButton { 2 | @apply bg-red-700 hover:bg-red-400 text-white font-bold py-2 px-4 rounded; 3 | } 4 | 5 | .font1 { 6 | @apply text-xs; 7 | } 8 | 9 | .font2 { 10 | @apply text-sm; 11 | } 12 | 13 | .font3 { 14 | @apply text-base; 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button2/Button2.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { specsValuesParser } from '@react-site-editor/functions'; 3 | import { argTypesControlsParser } from '@/utils'; 4 | import Button2, { propsSpecs } from './Button2.component'; 5 | import type { Button2Props } from './Button2.types'; 6 | 7 | const meta = { 8 | title: 'Buttons/Button2', 9 | argTypes: argTypesControlsParser(propsSpecs), 10 | component: Button2 11 | } satisfies Meta; 12 | 13 | type ButtonStory = StoryObj; 14 | 15 | export const Default = { 16 | // Get the default props from the specs 17 | args: specsValuesParser(propsSpecs) 18 | } satisfies ButtonStory; 19 | 20 | export default meta; 21 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button2/Button2.types.ts: -------------------------------------------------------------------------------- 1 | export interface Button2Props { 2 | text: string; 3 | fontSize: number; 4 | } 5 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button2/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button2.component'; 2 | export { propsSpecs as button2PropsSpecs } from './Button2.component'; 3 | export * from './Button2.types'; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button3/Button3.component.tsx: -------------------------------------------------------------------------------- 1 | import { specsValuesParser } from '@react-site-editor/functions'; 2 | import type { ComponentPropsSpecs } from '@react-site-editor/types'; 3 | import type { Button3Props } from './Button3.types'; 4 | import styles from './Button3.module.css'; 5 | 6 | const Button3: React.FunctionComponent = (props) => { 7 | const fontSize = `font${props.fontSize}` as keyof typeof styles; 8 | 9 | const handleClick = (event: React.MouseEvent) => { 10 | event.preventDefault(); 11 | console.log('Button 3 clicked'); 12 | }; 13 | 14 | return ( 15 | 18 | ); 19 | }; 20 | 21 | export const propsSpecs: ComponentPropsSpecs = { 22 | text: { 23 | value: 'Button3', 24 | control: { 25 | type: 'text' 26 | } 27 | }, 28 | fontSize: { 29 | value: 1, 30 | control: { 31 | type: 'select', 32 | options: [1, 2, 3] 33 | } 34 | }, 35 | iconName: 'ui-eject' 36 | }; 37 | 38 | Button3.defaultProps = specsValuesParser(propsSpecs); 39 | 40 | export default Button3; 41 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button3/Button3.module.css: -------------------------------------------------------------------------------- 1 | .baseButton { 2 | @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded; 3 | } 4 | 5 | .font1 { 6 | @apply text-xs; 7 | } 8 | 9 | .font2 { 10 | @apply text-sm; 11 | } 12 | 13 | .font3 { 14 | @apply text-base; 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button3/Button3.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { specsValuesParser } from '@react-site-editor/functions'; 3 | import { argTypesControlsParser } from '@/utils'; 4 | import Button3, { propsSpecs } from './Button3.component'; 5 | import type { Button3Props } from './Button3.types'; 6 | 7 | const meta = { 8 | title: 'Buttons/Button3', 9 | argTypes: argTypesControlsParser(propsSpecs), 10 | component: Button3 11 | } satisfies Meta; 12 | 13 | type ButtonStory = StoryObj; 14 | 15 | export const Default = { 16 | // Get the default props from the specs 17 | args: specsValuesParser(propsSpecs) 18 | } satisfies ButtonStory; 19 | 20 | export default meta; 21 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button3/Button3.types.ts: -------------------------------------------------------------------------------- 1 | export interface Button3Props { 2 | text: string; 3 | fontSize: number; 4 | } 5 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button3/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button3.component'; 2 | export { propsSpecs as button3PropsSpecs } from './Button3.component'; 3 | export * from './Button3.types'; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button5/Button5.component.tsx: -------------------------------------------------------------------------------- 1 | import { specsValuesParser } from '@react-site-editor/functions'; 2 | import type { ComponentPropsSpecs } from '@react-site-editor/types'; 3 | import type { Button5Props } from './Button5.types'; 4 | import styles from './Button5.module.css'; 5 | 6 | const Button5: React.FunctionComponent = (props) => { 7 | // The component definitions 8 | return ( 9 | <> 10 |
    {props.myProp}
    11 | 12 | ); 13 | }; 14 | 15 | export const propsSpecs: ComponentPropsSpecs = { 16 | // The default props of the component 17 | myProp: { 18 | value: 'Hello World!', 19 | control: { 20 | type: 'text' 21 | } 22 | }, 23 | iconName: 'ui-default' 24 | }; 25 | 26 | Button5.defaultProps = specsValuesParser(propsSpecs); 27 | 28 | export default Button5; 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button5/Button5.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | @apply uppercase; 3 | } 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button5/Button5.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { specsValuesParser } from '@react-site-editor/functions'; 3 | import { argTypesControlsParser } from '@/utils'; 4 | import Button5, { propsSpecs } from './Button5.component'; 5 | import type { Button5Props } from './Button5.types'; 6 | 7 | const meta = { 8 | title: 'Buttons/Button5', 9 | argTypes: argTypesControlsParser(propsSpecs), 10 | component: Button5 11 | } satisfies Meta; 12 | 13 | type Button5Story = StoryObj; 14 | 15 | export const Default = { 16 | args: specsValuesParser(propsSpecs) 17 | } satisfies Button5Story; 18 | 19 | export default meta; 20 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button5/Button5.types.ts: -------------------------------------------------------------------------------- 1 | export interface Button5Props { 2 | myProp: string; 3 | } 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/Button5/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button5.component'; 2 | export { propsSpecs as button5PropsSpecs } from './Button5.component'; 3 | export * from './Button5.types'; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Buttons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Button } from './Button'; 2 | export * from './Button'; 3 | 4 | export { default as Button2 } from './Button2'; 5 | export * from './Button2'; 6 | 7 | export { default as Button3 } from './Button3'; 8 | export * from './Button3'; 9 | 10 | export { default as Button5 } from './Button5'; 11 | export * from './Button5'; 12 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/ColumnLayout/ColumnLayout.component.tsx: -------------------------------------------------------------------------------- 1 | import { specsValuesParser } from '@react-site-editor/functions'; 2 | import type { ComponentPropsSpecs } from '@react-site-editor/types'; 3 | import styles from './ColumnLayout.module.css'; 4 | import type { ColumnLayoutProps } from './ColumnLayout.types'; 5 | import { ColumnLayoutOptions } from './ColumnLayout.types'; 6 | 7 | const ColumnLayout: React.FunctionComponent = (props) => { 8 | const columnCount = props.columnCount; 9 | 10 | const layout = { 11 | gridTemplateColumns: 12 | props.layout === ColumnLayoutOptions.DEFAULT ? '1fr '.repeat(columnCount) : props.layout 13 | }; 14 | 15 | const ListItem = () => { 16 | const itemsList = []; 17 | for (let i = 0; i < columnCount; i++) { 18 | itemsList.push( 19 |
    20 | 25 | 26 | 30 | 31 |
    32 | ); 33 | } 34 | 35 | return <>{itemsList}; 36 | }; 37 | 38 | return ( 39 |
    40 | 41 |
    42 | ); 43 | }; 44 | 45 | export const propsSpecs: ComponentPropsSpecs = { 46 | columnCount: { 47 | value: 3, 48 | control: { 49 | type: 'number', 50 | min: 2, 51 | max: 12 // Max for Tailwind CSS (12 columns) 52 | } 53 | }, 54 | layout: { 55 | value: ColumnLayoutOptions.DEFAULT, 56 | control: { 57 | type: 'grid-template', 58 | flowCountPropName: 'columnCount' 59 | } 60 | }, 61 | iconName: 'ui-table-columns' 62 | }; 63 | 64 | ColumnLayout.defaultProps = specsValuesParser(propsSpecs); 65 | 66 | export default ColumnLayout; 67 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/ColumnLayout/ColumnLayout.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | @apply grid grid-rows-1 w-full h-fit bg-slate-500 bg-opacity-5 gap-2; 3 | } 4 | 5 | .boxElement { 6 | @apply p-2 border border-dashed border-blue-500 rounded flex items-center justify-center bg-blue-200 text-blue-700; 7 | } 8 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/ColumnLayout/ColumnLayout.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { specsValuesParser } from '@react-site-editor/functions'; 3 | import { argTypesControlsParser } from '@/utils'; 4 | import ColumnLayout, { propsSpecs } from './ColumnLayout.component'; 5 | import type { ColumnLayoutProps } from './ColumnLayout.types'; 6 | 7 | const meta = { 8 | title: 'Layouts/Column Layout', 9 | component: ColumnLayout, 10 | argTypes: argTypesControlsParser(propsSpecs), 11 | decorators: [ 12 | // In order to get a stretched layout, we need to wrap the component in a div 13 | (StoryFn, storyContext) => ( 14 |
    21 | {StoryFn(storyContext.args)} 22 |
    23 | ) 24 | ] 25 | } satisfies Meta; 26 | 27 | type ColumnLayoutStory = StoryObj; 28 | 29 | export const Default = { 30 | args: specsValuesParser(propsSpecs) 31 | } satisfies ColumnLayoutStory; 32 | 33 | export default meta; 34 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/ColumnLayout/ColumnLayout.types.ts: -------------------------------------------------------------------------------- 1 | export interface ColumnLayoutProps { 2 | columnCount: number; 3 | layout: string; 4 | } 5 | 6 | export enum ColumnLayoutOptions { 7 | DEFAULT = 'default' 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/ColumnLayout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ColumnLayout.component'; 2 | export { propsSpecs as columnLayoutPropsSpecs } from './ColumnLayout.component'; 3 | export * from './ColumnLayout.types'; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/exposed/Layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ColumnLayout } from './ColumnLayout'; 2 | export * from './ColumnLayout'; 3 | -------------------------------------------------------------------------------- /packages/ui/src/components/icons/UiButtonPlay.tsx: -------------------------------------------------------------------------------- 1 | const UiButtonPlay = () => { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UiButtonPlay; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/icons/UiDefault.tsx: -------------------------------------------------------------------------------- 1 | const UiDefault = () => { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UiDefault; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/icons/UiEject.tsx: -------------------------------------------------------------------------------- 1 | const UiEject = () => { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UiEject; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/icons/UiTableColumns.tsx: -------------------------------------------------------------------------------- 1 | const UiTableColumns = () => { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UiTableColumns; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/icons/UiToggleOff.tsx: -------------------------------------------------------------------------------- 1 | const UiToggleOff = () => { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UiToggleOff; 14 | -------------------------------------------------------------------------------- /packages/ui/src/components/index.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentInfos, ExposedComponentsMap } from '@react-site-editor/types'; 2 | import * as allComponent from '../index'; 3 | 4 | async function getAllComponents(): Promise { 5 | const components: ComponentInfos[] = []; 6 | const files = import.meta.glob(`./exposed/**/*.component.*`); 7 | 8 | for (const filepath in files) { 9 | const filename = filepath.match(/.*\/(.+)\.component\..+$/)?.[1]; 10 | const directory = filepath.match(/\/(\w+)\/\w+\/\w+\.component\..+$/)?.[1]; 11 | 12 | if (filename) { 13 | components.push({ 14 | name: filename, 15 | group: directory || 'none', 16 | specs: (allComponent as ExposedComponentsMap)[ 17 | `${filename.charAt(0).toLowerCase() + filename.slice(1)}PropsSpecs` 18 | ] 19 | }); 20 | } 21 | } 22 | 23 | return components; 24 | } 25 | 26 | export const components = await getAllComponents(); 27 | -------------------------------------------------------------------------------- /packages/ui/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/exposed/Buttons'; 2 | 3 | export * from './components/exposed/Layouts'; 4 | 5 | export * from './components'; 6 | -------------------------------------------------------------------------------- /packages/ui/src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | @apply m-0 p-0; 7 | } 8 | -------------------------------------------------------------------------------- /packages/ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './main.css'; 2 | -------------------------------------------------------------------------------- /packages/ui/src/utils/argtypes-controls-parser.ts: -------------------------------------------------------------------------------- 1 | import type { ArgTypes } from '@storybook/react'; 2 | import type { ComponentPropsSpecs, ExtendedSpecs } from '@react-site-editor/types'; 3 | 4 | function getArgType(spec: ComponentPropsSpecs[keyof T | keyof ExtendedSpecs]) { 5 | if (!spec.value || (spec.value && ['grid-template'].includes(spec.control.type))) { 6 | return { 7 | defaultValue: spec, 8 | control: 'object' 9 | }; 10 | } 11 | 12 | const { value, control } = spec; 13 | const { type, ...controlProps } = control; 14 | 15 | if (['number', 'range', 'color', 'file'].includes(type)) { 16 | return { 17 | defaultValue: value, 18 | control: { type, ...controlProps } 19 | }; 20 | } 21 | 22 | return { 23 | defaultValue: value, 24 | control: type, 25 | ...controlProps 26 | }; 27 | } 28 | 29 | export function argTypesControlsParser(propsSpecs: ComponentPropsSpecs) { 30 | const argTypes: Partial> = {}; 31 | let key: keyof ComponentPropsSpecs; 32 | 33 | for (key in propsSpecs) { 34 | const spec = propsSpecs[key]; 35 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 36 | // @ts-ignore 37 | argTypes[key] = getArgType(spec); 38 | } 39 | 40 | return argTypes; 41 | } 42 | -------------------------------------------------------------------------------- /packages/ui/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { argTypesControlsParser } from './argtypes-controls-parser'; 2 | -------------------------------------------------------------------------------- /packages/ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/ui/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], 4 | theme: { 5 | extend: {} 6 | }, 7 | plugins: [] 8 | }; 9 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"], 8 | "@components/*": ["./src/components/*"], 9 | "@utils/*": ["./src/utils/*"], 10 | "@libs/*": ["./libs/*"] 11 | } 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | build: { 9 | target: 'esnext' 10 | }, 11 | resolve: { 12 | alias: [ 13 | { 14 | find: '@', 15 | replacement: resolve(__dirname, 'src') 16 | }, 17 | { 18 | find: '@components', 19 | replacement: resolve(__dirname, 'src/components') 20 | }, 21 | { 22 | find: '@utils', 23 | replacement: resolve(__dirname, 'src/utils') 24 | }, 25 | { 26 | find: '@libs', 27 | replacement: resolve(__dirname, 'libs') 28 | } 29 | ] 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'packages/*' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 8 | "jsx": "react-jsx", 9 | "declaration": true, 10 | "moduleResolution": "Node", 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "preserveSymlinks": true, 19 | "types": ["node"] 20 | }, 21 | "references": [ 22 | { 23 | "path": "./tsconfig.node.json" 24 | } 25 | ], 26 | "exclude": ["**/*/dist", "**/*/node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "moduleResolution": "Node", 5 | "allowSyntheticDefaultImports": true 6 | }, 7 | "include": ["apps/frontend/vite.config.ts", "packages/ui/vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "test": { 5 | "dependsOn": ["^test"] 6 | }, 7 | "build": { 8 | "dependsOn": ["^build"] 9 | } 10 | } 11 | } 12 | --------------------------------------------------------------------------------