├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml └── scripts │ └── bootstrap.sh ├── .github └── workflows │ └── main.yml ├── .vscode └── tasks.json ├── README.md ├── backend ├── .dockerignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── fly.toml ├── jest.config.js ├── package-lock.json ├── package.json ├── prisma │ ├── migrations │ │ ├── 20221119125957_firsto │ │ │ └── migration.sql │ │ ├── 20221127151657_add_company │ │ │ └── migration.sql │ │ ├── 20230102190522_add_profile_post_relation │ │ │ └── migration.sql │ │ ├── 20230105222721_add_like_profile_relation │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── schema.prisma │ └── seed.ts ├── src │ ├── api │ │ ├── emojis.ts │ │ ├── index.ts │ │ ├── like.ts │ │ ├── picture.ts │ │ ├── posts.ts │ │ ├── profile.ts │ │ └── users.ts │ ├── app.ts │ ├── index.ts │ ├── interfaces │ │ ├── ErrorResponse.ts │ │ └── MessageResponse.ts │ ├── lib │ │ └── prisma.ts │ ├── middleware │ │ ├── auth.ts │ │ └── errors.ts │ └── scripts │ │ └── supabase.sql ├── test │ ├── api.test.ts │ └── app.test.ts └── tsconfig.json ├── frontend ├── .dockerignore ├── .env.development ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── fly.toml ├── nginx │ └── nginx.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── ColorModeSwitcher.tsx │ ├── Logo.tsx │ ├── api.ts │ ├── components │ │ ├── Auth │ │ │ ├── Logo.tsx │ │ │ ├── OAuthButtonGroup.tsx │ │ │ ├── ProviderIcons.tsx │ │ │ └── Signin.tsx │ │ ├── LikeButton.css │ │ ├── LikeButton.tsx │ │ ├── MainNavigation.module.css │ │ ├── MainNavigation.tsx │ │ ├── NotFound.tsx │ │ ├── PersonalAvatar.tsx │ │ ├── PostActions.tsx │ │ ├── Posts.tsx │ │ ├── Profile.tsx │ │ ├── ProfileAvatar.tsx │ │ ├── ProfileDetail.tsx │ │ ├── Profiles.tsx │ │ ├── ProfilesActions.tsx │ │ ├── ProfilesLayout.tsx │ │ ├── ProtectedRoute.tsx │ │ ├── ReadMoreButton.tsx │ │ └── RootLayout.tsx │ ├── config │ │ ├── pickListOptions.ts │ │ └── supabase-client.ts │ ├── eventBus.ts │ ├── index.tsx │ ├── logo.svg │ ├── pages │ │ ├── NewPostPage.tsx │ │ ├── PostDetailPage.tsx │ │ ├── PostLayout.tsx │ │ ├── PostPage.tsx │ │ ├── ProfileLayout.tsx │ │ ├── ProfilePage.tsx │ │ ├── ProfilesPage.tsx │ │ └── WelcomePage.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── test-utils.tsx │ ├── types │ │ └── type.d.ts │ └── utils │ │ ├── constants.ts │ │ └── functions.ts └── tsconfig.json └── thumbnail.png /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " 17 | RUN npm install -g npm@latest 18 | 19 | RUN su node -c "yarn global add prisma" 20 | 21 | # WORKDIR /scripts 22 | # COPY /scripts/bootstrap.sh . 23 | # RUN chmod +x /scripts/bootstrap.sh 24 | # RUN ./bootstrap.sh -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | // Set *default* container specific settings.json values on container create. 9 | "settings": { 10 | "sqltools.connections": [ 11 | { 12 | "name": "Container database", 13 | "driver": "PostgreSQL", 14 | "previewLimit": 50, 15 | "server": "localhost", 16 | "port": 5432, 17 | "database": "supabase_react_auth_db", 18 | "username": "postgres", 19 | "password": "postgres" 20 | } 21 | ], 22 | "dotfiles.repository": "git@github.com:daniellaera/dotfiles.git", 23 | "editor.codeActionsOnSave": { 24 | "source.organizeImports": false 25 | }, 26 | "workbench.editor.titleScrollbarSizing": "large" 27 | }, 28 | // Add the IDs of extensions you want installed when the container is created. 29 | "extensions": [ 30 | "dbaeumer.vscode-eslint", 31 | "mutantdino.resourcemonitor", 32 | "eamodio.gitlens", 33 | "wix.vscode-import-cost", 34 | "esbenp.prettier-vscode", 35 | "prisma.prisma", 36 | "ms-azuretools.vscode-docker", 37 | "cweijan.vscode-postgresql-client2", 38 | "pkief.material-icon-theme" 39 | ], 40 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 41 | "forwardPorts": [5432], 42 | // Use 'postCreateCommand' to run commands after the container is created. 43 | "postCreateCommand": "bash .devcontainer/scripts/bootstrap.sh", 44 | // "postStartCommand": "cd backend && npm run db:seed", 45 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 46 | "remoteUser": "node", 47 | "features": { 48 | "powershell": "latest", 49 | "ghcr.io/dhoeric/features/flyctl:1": { 50 | "version": "latest" 51 | } 52 | } 53 | // for more features https://containers.dev/features 54 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | args: 9 | # Update 'VARIANT' to pick an LTS version of Node.js: 16, 14, 12. 10 | # Append -bullseye or -buster to pin to an OS version. 11 | # Use -bullseye variants on local arm64/Apple Silicon. 12 | VARIANT: 16-bullseye 13 | 14 | volumes: 15 | - ../..:/workspaces:cached 16 | 17 | # Overrides default command so things don't shut down after the process ends. 18 | command: sleep infinity 19 | 20 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 21 | network_mode: service:db 22 | 23 | # Uncomment the next line to use a non-root user for all processes. 24 | # user: node 25 | 26 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 27 | # (Adding the "ports" property to this file will not forward from a Codespace.) 28 | 29 | db: 30 | image: postgres:latest 31 | restart: unless-stopped 32 | volumes: 33 | - postgres-data:/var/lib/postgresql/data 34 | environment: 35 | POSTGRES_PASSWORD: postgres 36 | POSTGRES_USER: postgres 37 | POSTGRES_DB: supabase_react_auth_db 38 | 39 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 40 | # (Adding the "ports" property to this file will not forward from a Codespace.) 41 | 42 | volumes: 43 | postgres-data: -------------------------------------------------------------------------------- /.devcontainer/scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Installing frontend dependencies" 4 | cd "$PWD/frontend" 5 | /usr/local/bin/npm install 6 | 7 | echo "Installing backend dependencies" 8 | cd "../backend" 9 | /usr/local/bin/npm install -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: [push] 3 | 4 | env: 5 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 6 | REACT_APP_SUPABASE_URL: ${{ secrets.REACT_APP_SUPABASE_URL }} 7 | REACT_APP_SUPABASE_ANON_KEY: ${{ secrets.REACT_APP_SUPABASE_ANON_KEY }} 8 | REACT_APP_BACKEND_URL: ${{ secrets.REACT_APP_BACKEND_URL }} 9 | 10 | jobs: 11 | 12 | deploy: 13 | name: Deploy App 14 | runs-on: ubuntu-latest 15 | outputs: 16 | backend: ${{ steps.filter.outputs.backend }} 17 | frontend: ${{ steps.filter.outputs.frontend }} 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: dorny/paths-filter@v2 21 | id: filter 22 | with: 23 | filters: | 24 | backend: 25 | - 'backend/**' 26 | frontend: 27 | - 'frontend/**' 28 | 29 | backend: 30 | needs: deploy 31 | if: ${{ needs.deploy.outputs.backend == 'true' }} 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: superfly/flyctl-actions/setup-flyctl@master 36 | - run: flyctl deploy 37 | working-directory: ./backend 38 | 39 | frontend: 40 | needs: deploy 41 | if: ${{ needs.deploy.outputs.frontend == 'true' }} 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: superfly/flyctl-actions/setup-flyctl@master 46 | - run: | 47 | flyctl deploy --remote-only \ 48 | --build-arg REACT_APP_SUPABASE_URL=${{ secrets.REACT_APP_SUPABASE_URL }} \ 49 | --build-arg REACT_APP_SUPABASE_ANON_KEY=${{ secrets.REACT_APP_SUPABASE_ANON_KEY }} \ 50 | --build-arg REACT_APP_BACKEND_URL=${{ secrets.REACT_APP_BACKEND_URL }} 51 | working-directory: ./frontend 52 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "problemMatcher": [], 8 | "label": "npm: start", 9 | "detail": "react-scripts start" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## This projet has a major upgrade and will be soon archived. 2 | 3 | ## The new DevConnector has been moved here: https://github.com/daniellaera/admin-dashboard 4 | 5 | # Supabase React Auth with Prisma ORM 6 | 7 | When cloning this project in container volume (remote), a script located in `.devcontainer/scripts/bootstrap.sh` will bootstrap everything for you; that means, installing `frontend/package.json` & `backend/package.json` dependencies with npm. 8 | 9 | When container correctly bootstrapped make sure you have/create in your backend/frontend folders your own `.env` file because prisma schema and postgres local database requires a `DATABASE_URL`. 10 | 11 | You'll notice that in the `.devcontainer/devcontainer.json` line 22 the `"dotfiles.repository": "git@github.com:daniellaera/dotfiles.git"`, I clone the repository dotfiles via SSH. 12 | 13 | For doing this, you'll need to have a valid ssh key on your machine, ex: `ssh-keygen -t ed25519 -C "your_email@....com"` and add it to your own GitHub SSH settings, otherwise the remote repository won't be accessible and so the script `install.sh` won't be executed. 14 | 15 | ### Enjoy {^_^} 16 | 17 | ![Alt text](/thumbnail.png?raw=true "Optional Title") 18 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "jest": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "project": [ 9 | "**/tsconfig.json" 10 | ] 11 | }, 12 | "extends": "airbnb-typescript/base", 13 | "plugins": [ 14 | "import", 15 | "@typescript-eslint" 16 | ], 17 | "rules": { 18 | "comma-dangle": 0, 19 | "no-underscore-dangle": 0, 20 | "no-param-reassign": 0, 21 | "no-return-assign": 0, 22 | "camelcase": 0, 23 | "import/extensions": 0, 24 | "@typescript-eslint/no-redeclare": 0 25 | }, 26 | "settings": { 27 | "import/parsers": { 28 | "@typescript-eslint/parser": [ 29 | ".ts", 30 | ".tsx" 31 | ] 32 | }, 33 | "import/resolver": { 34 | "typescript": {} 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | dist -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # Install openssl for Prisma 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | RUN mkdir /app 11 | WORKDIR /app 12 | 13 | ADD package.json package-lock.json ./ 14 | RUN npm install --production=false 15 | 16 | # Setup production node_modules 17 | FROM base as production-deps 18 | 19 | RUN mkdir /app 20 | WORKDIR /app 21 | 22 | COPY --from=deps /app/node_modules /app/node_modules 23 | ADD package.json package-lock.json ./ 24 | RUN npm prune --production 25 | 26 | # Build the app 27 | FROM base as build 28 | 29 | ENV NODE_ENV=production 30 | 31 | RUN mkdir /app 32 | WORKDIR /app 33 | 34 | COPY --from=deps /app/node_modules /app/node_modules 35 | 36 | # If we're using Prisma, uncomment to cache the prisma schema 37 | ADD prisma . 38 | RUN npx prisma generate 39 | 40 | ADD . . 41 | RUN npm run build 42 | 43 | # Finally, build the production image with minimal footprint 44 | FROM base 45 | 46 | ENV NODE_ENV production 47 | 48 | RUN mkdir /app 49 | WORKDIR /app 50 | 51 | COPY --from=production-deps /app/node_modules /app/node_modules 52 | 53 | # Uncomment if using Prisma 54 | COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 55 | 56 | COPY --from=build /app/dist ./dist 57 | ADD . . 58 | 59 | CMD ["node", "dist/src/index.js"] -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2020 CJ R. 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Express API Starter with Typescript 2 | 3 | How to use this template: 4 | 5 | ```sh 6 | npx create-express-api --typescript --directory my-api-name 7 | ``` 8 | 9 | Includes API Server utilities: 10 | 11 | * [morgan](https://www.npmjs.com/package/morgan) 12 | * HTTP request logger middleware for node.js 13 | * [helmet](https://www.npmjs.com/package/helmet) 14 | * Helmet helps you secure your Express apps by setting various HTTP headers. It's not a silver bullet, but it can help! 15 | * [dotenv](https://www.npmjs.com/package/dotenv) 16 | * Dotenv is a zero-dependency module that loads environment variables from a `.env` file into `process.env` 17 | * [cors](https://www.npmjs.com/package/cors) 18 | * CORS is a node.js package for providing a Connect/Express middleware that can be used to enable CORS with various options. 19 | 20 | Development utilities: 21 | 22 | * [typescript](https://www.npmjs.com/package/typescript) 23 | * TypeScript is a language for application-scale JavaScript. 24 | * [ts-node](https://www.npmjs.com/package/ts-node) 25 | * TypeScript execution and REPL for node.js, with source map and native ESM support. 26 | * [nodemon](https://www.npmjs.com/package/nodemon) 27 | * nodemon is a tool that helps develop node.js based applications by automatically restarting the node application when file changes in the directory are detected. 28 | * [eslint](https://www.npmjs.com/package/eslint) 29 | * ESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code. 30 | * [typescript-eslint](https://typescript-eslint.io/) 31 | * Tooling which enables ESLint to support TypeScript. 32 | * [jest](https://www.npmjs.com/package/mocha) 33 | * Jest is a delightful JavaScript Testing Framework with a focus on simplicity. 34 | * [supertest](https://www.npmjs.com/package/supertest) 35 | * HTTP assertions made easy via superagent. 36 | 37 | ## Setup 38 | 39 | ``` 40 | npm install 41 | ``` 42 | 43 | ## Lint 44 | 45 | ``` 46 | npm run lint 47 | ``` 48 | 49 | ## Test 50 | 51 | ``` 52 | npm run test 53 | ``` 54 | 55 | ## Development 56 | 57 | ``` 58 | npm run dev 59 | ``` 60 | Adding new field/s in schema.prisma, run ex: `prisma migrate dev --name add_public_field` 61 | 62 | Migrate on Supabase `npx prisma migrate reset --preview-feature` 63 | 64 | Push existing image to Fly registry 65 | https://medium.com/geekculture/deploy-docker-images-on-fly-io-free-tier-afbfb1d390b1 66 | 67 | Listing secrets example `flyctl secrets list` 68 | Setting secrets example ` flyctl secrets set REACT_APP_SUPABASE_JWT_SECRET=` -------------------------------------------------------------------------------- /backend/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for supa-react-backend on 2023-01-04T18:45:36Z 2 | 3 | app = "supa-react-backend" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | NODE_ENV = "production" 10 | PORT = 8080 11 | 12 | [experimental] 13 | allowed_public_ports = [] 14 | auto_rollback = true 15 | 16 | [[services]] 17 | http_checks = [] 18 | internal_port = 8080 19 | processes = ["app"] 20 | protocol = "tcp" 21 | script_checks = [] 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | force_https = true 29 | handlers = ["http"] 30 | port = 80 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | modulePathIgnorePatterns: ['/dist/'], 6 | }; -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-api-starter-ts", 3 | "version": "1.2.0", 4 | "description": " A basic starter for an express.js API with Typescript", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "start": "ts-node src/index.ts", 8 | "dev": "nodemon src/index.ts", 9 | "build": "tsc", 10 | "lint": "eslint --fix src test", 11 | "test": "jest", 12 | "db:seed": "npx prisma migrate dev --name init && npx prisma db seed", 13 | "generate": "npx prisma migrate reset --preview-feature && npx prisma migrate deploy" 14 | }, 15 | "keywords": [], 16 | "author": "Daniel Laera", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/w3cj/express-api-starter.git" 20 | }, 21 | "license": "MIT", 22 | "dependencies": { 23 | "@prisma/client": "^4.8.0", 24 | "cors": "^2.8.5", 25 | "dotenv": "^16.0.1", 26 | "express": "^4.18.1", 27 | "helmet": "^5.1.1", 28 | "jsonwebtoken": "^8.5.1", 29 | "morgan": "^1.10.0" 30 | }, 31 | "devDependencies": { 32 | "@types/cors": "^2.8.12", 33 | "@types/express": "^4.17.13", 34 | "@types/jest": "^28.1.8", 35 | "@types/jsonwebtoken": "^8.5.9", 36 | "@types/morgan": "^1.9.3", 37 | "@types/node": "^18.11.18", 38 | "@types/supertest": "^2.0.12", 39 | "@typescript-eslint/eslint-plugin": "^5.34.0", 40 | "@typescript-eslint/parser": "^5.34.0", 41 | "eslint": "^8.22.0", 42 | "eslint-config-airbnb-typescript": "^17.0.0", 43 | "eslint-import-resolver-typescript": "^3.5.0", 44 | "eslint-plugin-import": "^2.26.0", 45 | "jest": "^28.1.3", 46 | "nodemon": "^2.0.19", 47 | "prisma": "^4.8.0", 48 | "supertest": "^6.2.4", 49 | "ts-jest": "^28.0.8", 50 | "ts-node": "^10.9.1", 51 | "typescript": "^4.8.3" 52 | }, 53 | "prisma": { 54 | "seed": "ts-node prisma/seed.ts" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221119125957_firsto/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Profile" ( 3 | "id" SERIAL NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "username" TEXT NOT NULL, 7 | "website" TEXT NOT NULL, 8 | "authorEmail" TEXT NOT NULL, 9 | "isPublic" BOOLEAN NOT NULL DEFAULT false, 10 | 11 | CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "ProgrammingLanguages" ( 16 | "id" SERIAL NOT NULL, 17 | "language" TEXT NOT NULL, 18 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | "updatedAt" TIMESTAMP(3) NOT NULL, 20 | "profileId" INTEGER NOT NULL, 21 | 22 | CONSTRAINT "ProgrammingLanguages_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "Post" ( 27 | "id" SERIAL NOT NULL, 28 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | "updatedAt" TIMESTAMP(3) NOT NULL, 30 | "title" TEXT NOT NULL, 31 | "content" TEXT, 32 | "published" BOOLEAN NOT NULL DEFAULT false, 33 | "viewCount" INTEGER NOT NULL DEFAULT 0, 34 | "authorEmail" TEXT NOT NULL, 35 | 36 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 37 | ); 38 | 39 | -- CreateTable 40 | CREATE TABLE "Like" ( 41 | "id" SERIAL NOT NULL, 42 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 43 | "postId" INTEGER NOT NULL, 44 | 45 | CONSTRAINT "Like_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "Comment" ( 50 | "id" SERIAL NOT NULL, 51 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 52 | "postId" INTEGER NOT NULL, 53 | 54 | CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") 55 | ); 56 | 57 | -- CreateTable 58 | CREATE TABLE "Picture" ( 59 | "id" SERIAL NOT NULL, 60 | "avatarUrl" TEXT NOT NULL, 61 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 62 | "updatedAt" TIMESTAMP(3) NOT NULL, 63 | "profileId" INTEGER NOT NULL, 64 | 65 | CONSTRAINT "Picture_pkey" PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "ProgrammingLanguages_language_id_key" ON "ProgrammingLanguages"("language", "id"); 70 | 71 | -- CreateIndex 72 | CREATE UNIQUE INDEX "Picture_profileId_key" ON "Picture"("profileId"); 73 | 74 | -- AddForeignKey 75 | ALTER TABLE "ProgrammingLanguages" ADD CONSTRAINT "ProgrammingLanguages_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 76 | 77 | -- AddForeignKey 78 | ALTER TABLE "Like" ADD CONSTRAINT "Like_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 79 | 80 | -- AddForeignKey 81 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 82 | 83 | -- AddForeignKey 84 | ALTER TABLE "Picture" ADD CONSTRAINT "Picture_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -------------------------------------------------------------------------------- /backend/prisma/migrations/20221127151657_add_company/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `company` to the `Profile` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Profile" ADD COLUMN "company" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230102190522_add_profile_post_relation/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `authorEmail` on the `Post` table. All the data in the column will be lost. 5 | - A unique constraint covering the columns `[authorEmail]` on the table `Profile` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "Post" DROP COLUMN "authorEmail", 10 | ADD COLUMN "profileId" INTEGER; 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Profile_authorEmail_key" ON "Profile"("authorEmail"); 14 | 15 | -- AddForeignKey 16 | ALTER TABLE "Post" ADD CONSTRAINT "Post_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230105222721_add_like_profile_relation/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `profileId` to the `Like` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Like" ADD COLUMN "profileId" INTEGER NOT NULL; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "Like" ADD CONSTRAINT "Like_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Profile { 14 | id Int @id @default(autoincrement()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | username String 18 | website String 19 | company String 20 | authorEmail String @unique 21 | isPublic Boolean @default(false) 22 | programmingLanguages ProgrammingLanguages[] 23 | picture Picture? 24 | posts Post[] 25 | likes Like[] 26 | } 27 | 28 | model ProgrammingLanguages { 29 | id Int @id @default(autoincrement()) 30 | language String 31 | createdAt DateTime @default(now()) 32 | updatedAt DateTime @updatedAt 33 | profileId Int 34 | Profile Profile @relation(fields: [profileId], references: [id]) 35 | 36 | @@unique([language, id]) 37 | } 38 | 39 | model Post { 40 | id Int @id @default(autoincrement()) 41 | createdAt DateTime @default(now()) 42 | updatedAt DateTime @updatedAt 43 | title String 44 | content String? 45 | published Boolean @default(false) 46 | viewCount Int @default(0) 47 | profileId Int? 48 | profile Profile? @relation(fields: [profileId], references: [id]) 49 | likes Like[] 50 | comments Comment[] 51 | } 52 | 53 | model Like { 54 | id Int @id @default(autoincrement()) 55 | createdAt DateTime @default(now()) 56 | postId Int 57 | Post Post @relation(fields: [postId], references: [id]) 58 | profileId Int 59 | Profile Profile @relation(fields: [profileId], references: [id]) 60 | } 61 | 62 | model Comment { 63 | id Int @id @default(autoincrement()) 64 | createdAt DateTime @default(now()) 65 | postId Int 66 | Post Post @relation(fields: [postId], references: [id]) 67 | } 68 | 69 | model Picture { 70 | id Int @id @default(autoincrement()) 71 | avatarUrl String 72 | createdAt DateTime @default(now()) 73 | updatedAt DateTime @updatedAt 74 | profileId Int @unique 75 | profile Profile @relation(fields: [profileId], references: [id]) 76 | } 77 | -------------------------------------------------------------------------------- /backend/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Prisma } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | const userData: Prisma.ProfileCreateInput[] = [ 6 | { 7 | username: 'John Doe', 8 | authorEmail: 'johndoe@gmail.com', 9 | website: 'johndoe.io', 10 | company: 'John&Co', 11 | posts: { 12 | create: [ 13 | { 14 | title: 'Join the Prisma Slack', 15 | content: 'https://slack.prisma.io', 16 | published: true, 17 | }, 18 | { 19 | title: 'Join the other community', 20 | content: 'https://slack.prisma.io', 21 | published: true, 22 | }, 23 | ], 24 | }, 25 | }, 26 | { 27 | username: 'Matthew Laing', 28 | authorEmail: 'matthewlaing@gmail.com', 29 | website: 'matthewlaing.io', 30 | company: 'Matthew&Co', 31 | posts: { 32 | create: [ 33 | { 34 | title: 'Join the Prisma Slack', 35 | content: 'https://slack.prisma.io', 36 | published: true, 37 | }, 38 | ], 39 | }, 40 | }, 41 | { 42 | username: 'Randy Smith', 43 | authorEmail: 'randysmith@gmail.com', 44 | website: 'radnysmith.io', 45 | company: 'Randy&Co', 46 | posts: { 47 | create: [ 48 | { 49 | title: 'Ask a question about Prisma on GitHub', 50 | content: 'https://www.github.com/prisma/prisma/discussions', 51 | published: true, 52 | viewCount: 128, 53 | }, 54 | ], 55 | }, 56 | }, 57 | ] 58 | 59 | async function main() { 60 | console.log(`Start seeding ...`) 61 | for (const u of userData) { 62 | const user = await prisma.profile.create({ 63 | data: u, 64 | }) 65 | console.log(`Created user with id: ${user.id}`) 66 | } 67 | console.log(`Seeding finished.`) 68 | } 69 | 70 | main() 71 | .then(async () => { 72 | await prisma.$disconnect() 73 | }) 74 | .catch(async (e) => { 75 | console.error(e) 76 | await prisma.$disconnect() 77 | process.exit(1) 78 | }) -------------------------------------------------------------------------------- /backend/src/api/emojis.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | const router = express.Router(); 4 | 5 | type EmojiResponse = string[]; 6 | 7 | router.get<{}, EmojiResponse>('/', (req, res) => { 8 | res.json(['😀', '😳', '🙄']); 9 | }); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /backend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import MessageResponse from '../interfaces/MessageResponse'; 4 | import emojis from './emojis'; 5 | import posts from './posts'; 6 | import users from './users'; 7 | import profile from './profile'; 8 | import picture from './picture'; 9 | import like from './like'; 10 | 11 | const router = express.Router(); 12 | 13 | router.get<{}, MessageResponse>('/', (req, res) => { 14 | res.json({ 15 | message: 'API - 👋🌎🌍🌏', 16 | }); 17 | }); 18 | 19 | router.use('/emojis', emojis); 20 | router.use('/users', users); 21 | router.use('/posts', posts); 22 | router.use('/profile', profile); 23 | router.use('/picture', picture); 24 | router.use('/like', like); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /backend/src/api/like.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import prisma from '../lib/prisma'; 3 | import { auth } from '../middleware/auth'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | const likes = await prisma.like.findMany({}); 9 | res.status(200).json(likes); 10 | }); 11 | 12 | router.post('/create', auth, async (req, res) => { 13 | const { profileId, postId } = req.body; 14 | try { 15 | const result = await prisma.like.create({ 16 | data: { 17 | postId: postId, 18 | profileId: profileId, 19 | }, 20 | }); 21 | res.status(200).json(result); 22 | } catch (error) { 23 | return res.status(400).json({ error: 'Unauthorized' }); 24 | } 25 | }); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /backend/src/api/picture.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import prisma from '../lib/prisma'; 3 | import { auth } from '../middleware/auth'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/pictureByProfileId/:profileId', async (req, res) => { 8 | const { profileId } = req.params; 9 | 10 | try { 11 | const post = await prisma.picture.findFirst({ 12 | where: { profileId: Number(profileId) }, 13 | }); 14 | 15 | res.json(post); 16 | } catch (error) { 17 | res.json({ error: `Picture with profileId ${profileId} does not exist in the database` }); 18 | } 19 | }); 20 | 21 | router.post('/create', auth, async (req, res) => { 22 | const { profileId, avatarUrl } = req.body; 23 | try { 24 | const result = await prisma.picture.create({ 25 | data: { 26 | avatarUrl, 27 | profileId: profileId, 28 | }, 29 | }); 30 | res.status(200).json(result); 31 | } catch (error) { 32 | return res.status(400).json({ error: 'Unauthorized' }); 33 | } 34 | }); 35 | 36 | router.put('/update', auth, async (req, res) => { 37 | const { profileId, avatarUrl } = req.body; 38 | 39 | const updateUser = await prisma.picture.update({ 40 | where: { 41 | profileId: Number(profileId), 42 | }, 43 | data: { 44 | avatarUrl, 45 | }, 46 | }); 47 | res.json(updateUser); 48 | }); 49 | 50 | export default router; 51 | -------------------------------------------------------------------------------- /backend/src/api/posts.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import prisma from '../lib/prisma'; 3 | import { auth } from '../middleware/auth'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | const posts = await prisma.post.findMany({ 9 | include: { 10 | profile: { 11 | select: { 12 | authorEmail: true, 13 | picture: { select: { avatarUrl: true } }, 14 | }, 15 | }, 16 | likes: { select: { id: true } }, 17 | }, 18 | }); 19 | res.status(200).json(posts); 20 | }); 21 | 22 | router.post('/create', auth, async (req, res) => { 23 | const { title, content, profileId } = req.body; 24 | try { 25 | const result = await prisma.post.create({ 26 | data: { 27 | title, 28 | content, 29 | profileId: profileId, 30 | }, 31 | }); 32 | res.status(200).json(result); 33 | } catch (error) { 34 | return res.status(400).json({ error: 'Unauthorized' }); 35 | } 36 | }); 37 | 38 | router.get('/post/:id', async (req, res) => { 39 | const { id } = req.params; 40 | try { 41 | const post = await prisma.post.findFirst({ 42 | where: { 43 | id: Number(id), 44 | }, 45 | include: { 46 | profile: { 47 | select: { 48 | authorEmail: true, 49 | picture: { select: { avatarUrl: true } }, 50 | }, 51 | }, 52 | likes: { select: { id: true } }, 53 | }, 54 | }); 55 | res.json(post); 56 | } catch (error) { 57 | res.json({ error: `Post with ID ${id} does not exist in the database` }); 58 | } 59 | }); 60 | 61 | export default router; 62 | -------------------------------------------------------------------------------- /backend/src/api/profile.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import prisma from '../lib/prisma'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', async (req, res) => { 7 | const profiles = await prisma.profile.findMany({ 8 | include: { 9 | programmingLanguages: { 10 | select: { 11 | language: true, 12 | }, 13 | }, 14 | picture: { 15 | select: { 16 | avatarUrl: true, 17 | }, 18 | }, 19 | }, 20 | }); 21 | res.status(200).json(profiles); 22 | }); 23 | 24 | router.post('/create', async (req, res) => { 25 | const { username, website, authorEmail, programmingLanguages, company } = req.body; 26 | 27 | const result = await prisma.profile.create({ 28 | data: { 29 | username, 30 | website, 31 | authorEmail, 32 | company, 33 | programmingLanguages: { 34 | connectOrCreate: programmingLanguages.map((lang: string, id: number) => ({ 35 | create: { language: lang }, 36 | where: { id: id }, 37 | })), 38 | }, 39 | }, 40 | }); 41 | res.json(result); 42 | }); 43 | 44 | router.put('/updateById/:profileId', async (req, res) => { 45 | const { profileId } = req.params; 46 | const { username, website, company, programmingLanguages, isPublic } = req.body; 47 | 48 | // we delete first all record with profileId 49 | await prisma.$transaction([prisma.programmingLanguages.deleteMany({ where: { profileId: Number(profileId) } })]); 50 | 51 | // then we repopulate programmingLanguages 52 | const profileUpdated = await prisma.profile.update({ 53 | where: { id: Number(profileId) }, 54 | data: { 55 | username: username, 56 | website: website, 57 | company: company, 58 | isPublic: isPublic, 59 | programmingLanguages: { 60 | connectOrCreate: programmingLanguages.map((lang: string) => ({ 61 | create: { language: lang }, 62 | where: { id: Number(profileId) }, 63 | })), 64 | }, 65 | }, 66 | }); 67 | 68 | res.json(profileUpdated); 69 | }); 70 | 71 | router.get('/findProfileByEmail/:authorEmail', async (req, res) => { 72 | const { authorEmail } = req.params; 73 | 74 | try { 75 | const profile = await prisma.profile.findFirst({ 76 | where: { authorEmail }, 77 | include: { 78 | programmingLanguages: { 79 | select: { 80 | language: true, 81 | }, 82 | }, 83 | picture: { select: { avatarUrl: true } }, 84 | }, 85 | }); 86 | res.json(profile); 87 | } catch (error) { 88 | res.json({ error: `Profile with authorEmail ${authorEmail} does not exist in the database` }); 89 | } 90 | }); 91 | 92 | router.put('/publishProfile/:profileId', async (req, res) => { 93 | const { profileId } = req.params; 94 | const profileUpdated = await prisma.profile.update({ 95 | where: { id: Number(profileId) }, 96 | data: { isPublic: true }, 97 | }); 98 | res.json(profileUpdated); 99 | }); 100 | 101 | router.get('/:profileId', async (req, res) => { 102 | const { profileId } = req.params; 103 | 104 | const profile = await prisma.profile.findFirst({ 105 | where: { id: Number(profileId) }, 106 | }); 107 | res.json(profile); 108 | }); 109 | 110 | export default router; 111 | -------------------------------------------------------------------------------- /backend/src/api/users.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | const router = express.Router(); 4 | 5 | router.get('/', async (req, res) => { 6 | //const users = await prisma.user.findMany() 7 | res.json('hello'); 8 | }); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import helmet from 'helmet'; 4 | import morgan from 'morgan'; 5 | import api from './api'; 6 | import MessageResponse from './interfaces/MessageResponse'; 7 | import * as middlewares from './middleware/errors'; 8 | 9 | require('dotenv').config(); 10 | 11 | const app = express(); 12 | 13 | app.use(morgan('dev')); 14 | app.use(helmet()); 15 | app.use(cors()); 16 | app.use(express.json()); 17 | 18 | app.get<{}, MessageResponse>('/', (req, res) => { 19 | res.json({ 20 | message: 'Hello! 🚀🦄🌈✨👋🌎✨🌈🦄🚀', 21 | }); 22 | }); 23 | 24 | app.use('/api/v1', api); 25 | 26 | app.use(middlewares.notFound); 27 | app.use(middlewares.errorHandler); 28 | 29 | export default app; 30 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | 3 | const port = process.env.PORT || 8080; 4 | app.listen(port, () => { 5 | /* eslint-disable no-console */ 6 | console.log(`Listening: http://localhost:${port}`); 7 | /* eslint-enable no-console */ 8 | }); 9 | -------------------------------------------------------------------------------- /backend/src/interfaces/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import MessageResponse from './MessageResponse'; 2 | 3 | export default interface ErrorResponse extends MessageResponse { 4 | stack?: string; 5 | } -------------------------------------------------------------------------------- /backend/src/interfaces/MessageResponse.ts: -------------------------------------------------------------------------------- 1 | export default interface MessageResponse { 2 | message: string; 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | interface CustomNodeJsGlobal { 4 | prisma: PrismaClient; 5 | } 6 | 7 | declare const global: CustomNodeJsGlobal; 8 | 9 | const prisma = global.prisma || new PrismaClient(); 10 | 11 | if (process.env.NODE_ENV === 'production') { 12 | global.prisma = new PrismaClient(); 13 | } else { 14 | if (!global.prisma) { 15 | global.prisma = new PrismaClient(); 16 | } 17 | 18 | global.prisma = global.prisma; 19 | } 20 | 21 | export default prisma; 22 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import jwt, { JwtPayload } from 'jsonwebtoken'; 3 | 4 | export const auth = async (req: Request, res: Response, next: NextFunction) => { 5 | try { 6 | //const token = req.header('Authorization')?.replace('Bearer ', ''); 7 | const token = req.header('Authorization')?.split(' ')[1]; 8 | 9 | const supabaseSecret: string = `${process.env.SUPABASE_JWT_SECRET}`; 10 | 11 | if (token) { 12 | //throw new Error(); 13 | //const checkJwt = jwt.decode(token, { complete: true, json: true }); 14 | 15 | jwt.verify(token, supabaseSecret) as JwtPayload; 16 | } else { 17 | res.status(401).json({ msg: 'No token, auth denied!' }); 18 | } 19 | 20 | next(); 21 | } catch (err) { 22 | return res.status(400).json({ error: 'Invalid token, auth denied!' }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /backend/src/middleware/errors.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | import ErrorResponse from '../interfaces/ErrorResponse'; 4 | 5 | export function notFound(req: Request, res: Response, next: NextFunction) { 6 | res.status(404); 7 | const error = new Error(`🔍 - Not Found - ${req.originalUrl}`); 8 | next(error); 9 | } 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) { 13 | const statusCode = res.statusCode !== 200 ? res.statusCode : 500; 14 | res.status(statusCode); 15 | res.json({ 16 | message: err.message, 17 | stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/scripts/supabase.sql: -------------------------------------------------------------------------------- 1 | insert into storage.buckets (id, name) 2 | values ('images', 'images'); 3 | 4 | -- Set up access controls for storage. 5 | -- See https://supabase.com/docs/guides/storage#policy-examples for more details. 6 | create policy "Avatar images are publicly accessible." on storage.objects 7 | for select using (bucket_id = 'images'); 8 | 9 | create policy "Anyone can upload an avatar." on storage.objects 10 | for insert with check (bucket_id = 'images'); 11 | 12 | grant usage on schema public to postgres, anon, authenticated, service_role; 13 | 14 | grant all privileges on all tables in schema public to postgres, anon, authenticated, service_role; -------------------------------------------------------------------------------- /backend/test/api.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import app from '../src/app'; 4 | 5 | describe('GET /api/v1', () => { 6 | it('responds with a json message', (done) => { 7 | request(app) 8 | .get('/api/v1') 9 | .set('Accept', 'application/json') 10 | .expect('Content-Type', /json/) 11 | .expect(200, { 12 | message: 'API - 👋🌎🌍🌏', 13 | }, done); 14 | }); 15 | }); 16 | 17 | describe('GET /api/v1/emojis', () => { 18 | it('responds with a json message', (done) => { 19 | request(app) 20 | .get('/api/v1/emojis') 21 | .set('Accept', 'application/json') 22 | .expect('Content-Type', /json/) 23 | .expect(200, ['😀', '😳', '🙄'], done); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /backend/test/app.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import app from '../src/app'; 4 | 5 | describe('app', () => { 6 | it('responds with a not found message', (done) => { 7 | request(app) 8 | .get('/what-is-this-even') 9 | .set('Accept', 'application/json') 10 | .expect('Content-Type', /json/) 11 | .expect(404, done); 12 | }); 13 | }); 14 | 15 | describe('GET /', () => { 16 | it('responds with a json message', (done) => { 17 | request(app) 18 | .get('/') 19 | .set('Accept', 'application/json') 20 | .expect('Content-Type', /json/) 21 | .expect(200, { 22 | message: '🦄🌈✨👋🌎🌍🌏✨🌈🦄', 23 | }, done); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": true, 5 | "target": "esnext", 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noImplicitAny": true, 10 | "strict": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": [ 14 | "./*.js", 15 | "src/**/*.ts", 16 | "test/**/*.ts", 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_URL="http://localhost:8080" -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "trailingComma": "none", 5 | "tabWidth": 2, 6 | "arrowParens": "avoid", 7 | "jsxBracketSameLine": true 8 | } 9 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine as build 2 | 3 | WORKDIR /app 4 | 5 | ARG REACT_APP_SUPABASE_URL 6 | ARG REACT_APP_SUPABASE_ANON_KEY 7 | ARG REACT_APP_BACKEND_URL 8 | 9 | ENV REACT_APP_SUPABASE_URL=$REACT_APP_SUPABASE_URL 10 | ENV REACT_APP_SUPABASE_ANON_KEY=$REACT_APP_SUPABASE_ANON_KEY 11 | ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL 12 | 13 | ARG NODE_ENV=production 14 | RUN echo ${NODE_ENV} 15 | 16 | COPY package*.json ./ 17 | RUN npm install 18 | COPY . . 19 | RUN npm run build 20 | 21 | FROM nginx:1.19 22 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf 23 | COPY --from=build /app/build /usr/share/nginx/html -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with 2 | [Create React App](https://github.com/facebook/create-react-app). 3 | 4 | ## Available Scripts 5 | 6 | In the project directory, you can run: 7 | 8 | ### `npm start` 9 | 10 | Mastering React Mutations 11 | https://tkdodo.eu/blog/mastering-mutations-in-react-query 12 | 13 | Runs the app in the development mode.
Open 14 | [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.
You will also see any lint errors 17 | in the console. 18 | 19 | ### `npm test` 20 | 21 | Launches the test runner in the interactive watch mode.
See the section 22 | about 23 | [running tests](https://facebook.github.io/create-react-app/docs/running-tests) 24 | for more information. 25 | 26 | ### `npm run build` 27 | 28 | Builds the app for production to the `build` folder.
It correctly bundles 29 | React in production mode and optimizes the build for the best performance. 30 | 31 | The build is minified and the filenames include the hashes.
Your app is 32 | ready to be deployed! 33 | 34 | See the section about 35 | [deployment](https://facebook.github.io/create-react-app/docs/deployment) for 36 | more information. 37 | 38 | ### `npm run eject` 39 | 40 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 41 | 42 | If you aren’t satisfied with the build tool and configuration choices, you can 43 | `eject` at any time. This command will remove the single build dependency from 44 | your project. 45 | 46 | Instead, it will copy all the configuration files and the transitive 47 | dependencies (webpack, Babel, ESLint, etc) right into your project so you have 48 | full control over them. All of the commands except `eject` will still work, but 49 | they will point to the copied scripts so you can tweak them. At this point 50 | you’re on your own. 51 | 52 | You don’t have to ever use `eject`. The curated feature set is suitable for 53 | small and middle deployments, and you shouldn’t feel obligated to use this 54 | feature. However we understand that this tool wouldn’t be useful if you couldn’t 55 | customize it when you are ready for it. 56 | 57 | ## Learn More 58 | 59 | You can learn more in the 60 | [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 61 | 62 | To learn React, check out the [React documentation](https://reactjs.org/). 63 | 64 | React-Query topics : https://stackoverflow.com/questions/67091583/react-query-how-can-i-access-my-queries-in-multiple-components 65 | -------------------------------------------------------------------------------- /frontend/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for supa-react-frontend on 2022-11-21T21:35:04Z 2 | 3 | app = "supa-react-frontend" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | 10 | [experimental] 11 | allowed_public_ports = [] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | http_checks = [] 16 | internal_port = 80 17 | processes = ["app"] 18 | protocol = "tcp" 19 | script_checks = [] 20 | [services.concurrency] 21 | hard_limit = 25 22 | soft_limit = 20 23 | type = "connections" 24 | 25 | [[services.ports]] 26 | force_https = true 27 | handlers = ["http"] 28 | port = 80 29 | 30 | [[services.ports]] 31 | handlers = ["tls", "http"] 32 | port = 443 33 | 34 | [[services.tcp_checks]] 35 | grace_period = "1s" 36 | interval = "15s" 37 | restart_limit = 0 38 | timeout = "2s" 39 | 40 | [[statics]] 41 | guest_path = "/usr/share/nginx/html/" 42 | url_prefix = "/" -------------------------------------------------------------------------------- /frontend/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 80; 10 | server_name localhost; 11 | 12 | root /usr/share/nginx/html; 13 | index index.html index.htm; 14 | include /etc/nginx/mime.types; 15 | 16 | gzip on; 17 | gzip_min_length 1000; 18 | gzip_proxied expired no-cache no-store private auth; 19 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 20 | 21 | location / { 22 | try_files $uri $uri/ /index.html; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/icons": "^2.0.11", 7 | "@chakra-ui/react": "^2.4.1", 8 | "@emotion/react": "^11.10.5", 9 | "@emotion/styled": "^11.10.5", 10 | "@supabase/supabase-js": "^2.0.0", 11 | "@testing-library/jest-dom": "^5.16.5", 12 | "@testing-library/react": "^13.4.0", 13 | "@testing-library/user-event": "^14.4.3", 14 | "@types/jest": "^28.1.8", 15 | "@types/node": "^12.20.55", 16 | "@types/react": "^18.0.21", 17 | "@types/react-dom": "^18.0.6", 18 | "axios": "^0.27.2", 19 | "chakra-react-select": "^4.3.0", 20 | "dotenv": "^16.0.3", 21 | "framer-motion": "^6.5.1", 22 | "moment": "^2.29.4", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-error-boundary": "^3.1.4", 26 | "react-icons": "^4.6.0", 27 | "react-query": "^3.39.2", 28 | "react-router-dom": "^6.4.1", 29 | "react-scripts": "5.0.1", 30 | "typescript": "^4.8.3", 31 | "web-vitals": "^2.1.4" 32 | }, 33 | "scripts": { 34 | "start": "react-scripts start", 35 | "build": "react-scripts build", 36 | "test": "react-scripts test", 37 | "eject": "react-scripts eject" 38 | }, 39 | "eslintConfig": { 40 | "extends": "react-app" 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniellaera/supabase-react-auth/7cf0838a7e0641731e403195aafbac909c001ebc/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | DevConnector 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniellaera/supabase-react-auth/7cf0838a7e0641731e403195aafbac909c001ebc/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniellaera/supabase-react-auth/7cf0838a7e0641731e403195aafbac909c001ebc/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { screen } from "@testing-library/react" 3 | import { render } from "./test-utils" 4 | import { App } from "./App" 5 | 6 | test("renders learn react link", () => { 7 | render() 8 | const linkElement = screen.getByText(/learn chakra/i) 9 | expect(linkElement).toBeInTheDocument() 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ChakraProvider, theme, useToast } from '@chakra-ui/react'; 2 | import { AuthChangeEvent, Session } from '@supabase/supabase-js'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 5 | import Invoices from './components/Profile'; 6 | import NotFound from './components/NotFound'; 7 | import ProtectedRoute from './components/ProtectedRoute'; 8 | import RootLayout from './components/RootLayout'; 9 | import ProfilesLayout from './components/ProfilesLayout'; 10 | import { supabaseClient } from './config/supabase-client'; 11 | import NewPostPage from './pages/NewPostPage'; 12 | import PostDetailPage from './pages/PostDetailPage'; 13 | import PostLayout from './pages/PostLayout'; 14 | import PostPage from './pages/PostPage'; 15 | import ProfileLayout from './pages/ProfileLayout'; 16 | import ProfilePage from './pages/ProfilePage'; 17 | import WelcomePage from './pages/WelcomePage'; 18 | import ProfilesPage from './pages/ProfilesPage'; 19 | import Signin from './components/Auth/Signin'; 20 | import Profile from './components/Profile'; 21 | 22 | export const App = () => { 23 | const [signedIn, setSignedIn] = useState(true); 24 | const [session, setSession] = useState(); 25 | const toast = useToast(); 26 | const [event, setEvent] = useState() 27 | 28 | supabaseClient.auth.onAuthStateChange((event) => { 29 | if (event === 'SIGNED_OUT') { 30 | setSignedIn(false); 31 | setEvent(event) 32 | } 33 | if (event === 'SIGNED_IN') { 34 | setSignedIn(true); 35 | setEvent(event) 36 | } 37 | }); 38 | 39 | const showToast = useCallback((e: any) => { 40 | toast({ 41 | position: 'bottom', 42 | render: () => ( 43 | 44 | {e === 'SIGNED_IN' ? `Signed In` : `Signed Out`} 45 | 46 | ), 47 | }) 48 | }, [toast]); 49 | 50 | useEffect(() => { 51 | if (event) showToast(event) 52 | 53 | const setData = async () => { 54 | const { data: { session }, error } = await supabaseClient.auth.getSession(); 55 | if (error) throw error; 56 | if (session) { 57 | setSession(session) 58 | //console.log('session from App', session.access_token) 59 | setSignedIn(true) 60 | } else { 61 | setSignedIn(false) 62 | } 63 | }; 64 | setData() 65 | }, [event, showToast]) 66 | 67 | return ( 68 | 69 | 70 | 71 | 72 | } /> 73 | }> 74 | } /> 75 | 76 | } /> 77 | }> 78 | } /> 79 | 80 | } /> 81 | 85 | 86 | 87 | } 88 | /> 89 | {/* } /> */} 90 | 92 | 93 | 94 | }> 95 | } /> 96 | 97 | } /> 98 | 99 | 100 | 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /frontend/src/ColorModeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | useColorMode, 4 | useColorModeValue, 5 | IconButton, 6 | IconButtonProps, 7 | } from "@chakra-ui/react" 8 | import { FaMoon, FaSun } from "react-icons/fa" 9 | 10 | type ColorModeSwitcherProps = Omit 11 | 12 | export const ColorModeSwitcher: React.FC = (props) => { 13 | const { toggleColorMode } = useColorMode() 14 | const text = useColorModeValue("dark", "light") 15 | const SwitchIcon = useColorModeValue(FaMoon, FaSun) 16 | 17 | return ( 18 | } 26 | aria-label={`Switch to ${text} mode`} 27 | {...props} 28 | /> 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | chakra, 4 | keyframes, 5 | ImageProps, 6 | forwardRef, 7 | usePrefersReducedMotion, 8 | } from "@chakra-ui/react" 9 | import logo from "./logo.svg" 10 | 11 | const spin = keyframes` 12 | from { transform: rotate(0deg); } 13 | to { transform: rotate(360deg); } 14 | ` 15 | 16 | export const Logo = forwardRef((props, ref) => { 17 | const prefersReducedMotion = usePrefersReducedMotion() 18 | 19 | const animation = prefersReducedMotion 20 | ? undefined 21 | : `${spin} infinite 20s linear` 22 | 23 | return 24 | }) 25 | -------------------------------------------------------------------------------- /frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosResponse } from 'axios'; 2 | 3 | const baseUrl: string = `${process.env.REACT_APP_BACKEND_URL}/api/v1/posts`; 4 | const profileUrl: string = `${process.env.REACT_APP_BACKEND_URL}/api/v1/profile`; 5 | const pictureUrl: string = `${process.env.REACT_APP_BACKEND_URL}/api/v1/picture`; 6 | const likeUrl: string = `${process.env.REACT_APP_BACKEND_URL}/api/v1/like`; 7 | 8 | export async function getProfiles() { 9 | const { data } = await axios.get(profileUrl); 10 | return data; 11 | } 12 | 13 | export async function fetchPosts() { 14 | const { data } = await axios.get(baseUrl); 15 | return data; 16 | } 17 | 18 | export const getPost = async (id: number): Promise => { 19 | const response: AxiosResponse = await axios.get(baseUrl + '/post/' + id); 20 | return response; 21 | }; 22 | 23 | export const getProfileByAuthorEmail = async (authorEmail: string): Promise => { 24 | try { 25 | const response: AxiosResponse = await axios.get(`${profileUrl}/findProfileByEmail/${authorEmail}`); 26 | return response; 27 | } catch (error: any) { 28 | throw new AxiosError(error); 29 | } 30 | }; 31 | 32 | export async function addPost(post: Omit, accToken: string) { 33 | const response = await axios.post(`${baseUrl}/create`, post, { headers: { Authorization: `token ${accToken}` } }); 34 | return response; 35 | } 36 | 37 | export const deleteTodo = async (id: number): Promise => { 38 | try { 39 | const deletedTodo: AxiosResponse = await axios.delete(`${baseUrl}/delete-todo/${id}`); 40 | return deletedTodo; 41 | } catch (error: any) { 42 | throw new Error(error); 43 | } 44 | }; 45 | 46 | export async function createProfile(profile: Omit) { 47 | const response = await axios.post(`${profileUrl}/create`, profile); 48 | return response; 49 | } 50 | 51 | export async function saveProfile(profile: IProfile) { 52 | const response = await axios.put(`${profileUrl}/updateById/${profile.id}`, profile); 53 | return response; 54 | } 55 | 56 | export async function publishProfile(profileId: number) { 57 | const response = await axios.put(`${profileUrl}/publishProfile/${profileId}`); 58 | return response; 59 | } 60 | 61 | export async function createPicture(picture: Omit, accToken: string) { 62 | const response = await axios.post(`${pictureUrl}/create`, picture, { 63 | headers: { Authorization: `token ${accToken}` } 64 | }); 65 | return response; 66 | } 67 | 68 | export async function updatePicture(picture: Omit, accToken: string) { 69 | const response = await axios.put(`${pictureUrl}/update`, picture, { 70 | headers: { Authorization: `token ${accToken}` } 71 | }); 72 | return response; 73 | } 74 | 75 | export async function getPictureByProfileId(profileId: number) { 76 | const response = await axios.get(`${pictureUrl}/pictureByProfileId/${profileId}`); 77 | return response; 78 | } 79 | 80 | export async function addLike(like: Omit, accToken: string) { 81 | const response = await axios.post(`${likeUrl}/create`, like, { 82 | headers: { Authorization: `token ${accToken}` } 83 | }); 84 | return response; 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/components/Auth/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { chakra, HTMLChakraProps } from '@chakra-ui/react' 2 | 3 | export const Logo = (props: HTMLChakraProps<'svg'>) => ( 4 | 12 | 18 | 19 | ) -------------------------------------------------------------------------------- /frontend/src/components/Auth/OAuthButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup, VisuallyHidden } from '@chakra-ui/react'; 2 | import { GitHubIcon, GoogleIcon, TwitterIcon } from './ProviderIcons'; 3 | 4 | const providers = [ 5 | { name: 'GitHub', icon: }, 6 | { name: 'Google', icon: }, 7 | { name: 'Twitter', icon: } 8 | ]; 9 | 10 | interface Props { 11 | childToParent(socialName: string): any; 12 | } 13 | 14 | export const OAuthButtonGroup = ({ childToParent }: Props) => { 15 | 16 | function signInWithSocial(name: string): void { 17 | childToParent(name); 18 | } 19 | 20 | return ( 21 | 22 | {providers.map(({ name, icon }, i: number) => ( 23 | 27 | ))} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/components/Auth/ProviderIcons.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from '@chakra-ui/react' 2 | 3 | export const GoogleIcon = createIcon({ 4 | displayName: 'GoogleIcon', 5 | path: ( 6 | 7 | 11 | 15 | 19 | 23 | 24 | ), 25 | }) 26 | 27 | export const GitHubIcon = createIcon({ 28 | displayName: 'GitHubIcon', 29 | path: ( 30 | 34 | ), 35 | }) 36 | 37 | export const TwitterIcon = createIcon({ 38 | displayName: 'TwitterIcon', 39 | path: ( 40 | 44 | ), 45 | }) -------------------------------------------------------------------------------- /frontend/src/components/Auth/Signin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Checkbox, 5 | Container, 6 | Divider, 7 | FormControl, 8 | FormLabel, 9 | Heading, 10 | HStack, 11 | IconButton, 12 | Input, 13 | InputGroup, 14 | InputRightElement, 15 | Stack, 16 | Tab, 17 | TabList, 18 | TabPanel, 19 | TabPanels, 20 | Tabs, 21 | Text, 22 | useColorModeValue, 23 | useDisclosure, 24 | useToast 25 | } from '@chakra-ui/react'; 26 | import { Session, User } from '@supabase/supabase-js'; 27 | import { useCallback, useEffect, useRef, useState } from 'react'; 28 | import { HiEye, HiEyeOff } from 'react-icons/hi'; 29 | import { Navigate, redirect, useNavigate } from 'react-router-dom'; 30 | import { supabaseClient } from '../../config/supabase-client'; 31 | import { regex } from '../../utils/constants'; 32 | import { Logo } from './Logo'; 33 | import { OAuthButtonGroup } from './OAuthButtonGroup'; 34 | 35 | export const Signin = () => { 36 | const formBackground = useColorModeValue('gray.100', 'gray.700'); 37 | const [authButtonState, setAuthButtonState] = useState(false); 38 | const toast = useToast(); 39 | const navigate = useNavigate(); 40 | const [magicEmail, setMagicEmail] = useState(''); 41 | const [email, setEmail] = useState(''); 42 | const [password, setPassword] = useState(''); 43 | const [loadingGithub, setLoadingGithub] = useState(false); 44 | const [loading, setLoading] = useState(false); 45 | const [magicEmailDisabled, setMagicEmailDisabled] = useState(true); 46 | const [emailDisabled, setEmailDisabled] = useState(true); 47 | const [Rmsg, setRMsg] = useState(''); // Registration message 48 | const [Lmsg, setLMsg] = useState(''); // Login message 49 | const [user, setUser] = useState(); // User object after registration / login 50 | const [session, setSession] = useState(); 51 | 52 | const { isOpen, onToggle } = useDisclosure() 53 | 54 | const onClickReveal = () => { 55 | onToggle() 56 | } 57 | 58 | function signInWithSocial(socialName: string): void { 59 | switch (socialName) { 60 | case 'Google': 61 | consoleGoogle() 62 | break; 63 | case 'GitHub': 64 | signGithub() 65 | break; 66 | case 'Twitter': 67 | consoleTwitter() 68 | break; 69 | default: 70 | break; 71 | } 72 | } 73 | 74 | const signInWithGithub = async () => { 75 | try { 76 | setLoadingGithub(true) 77 | const { error } = await supabaseClient.auth.signInWithOAuth({ 78 | provider: 'github', 79 | }); 80 | if (error) throw error 81 | } catch (error: any) { 82 | toast({ 83 | title: 'Error', 84 | position: 'top', 85 | description: error.error_description || error.message, 86 | status: 'error', 87 | duration: 5000, 88 | isClosable: true 89 | }); 90 | } 91 | }; 92 | 93 | const checkMagicEmail = (e: any) => { 94 | setMagicEmailDisabled(!regex.test(e.target.value)); 95 | setMagicEmail(e.target.value) 96 | } 97 | 98 | const checkEmail = (e: any) => { 99 | setEmailDisabled(!regex.test(e.target.value)); 100 | setEmail(e.target.value) 101 | } 102 | 103 | const handlePassword = (e: any) => { 104 | setPassword(e.target.value) 105 | } 106 | 107 | const handleLoginWithMagic = async (email: string) => { 108 | try { 109 | setLoading(true); 110 | const { error } = await supabaseClient.auth.signInWithOtp({ email }); 111 | if (error) throw error; 112 | toast({ 113 | title: 'Account confirmed.', 114 | position: 'top', 115 | description: 'Check your email for the login link', 116 | status: 'success', 117 | duration: 5000, 118 | isClosable: true 119 | }); 120 | } catch (error: any) { 121 | toast({ 122 | title: 'Error', 123 | position: 'top', 124 | description: error.error_description || error.message, 125 | status: 'error', 126 | duration: 5000, 127 | isClosable: true 128 | }); 129 | } finally { 130 | setLoading(false); 131 | setEmail('') 132 | } 133 | }; 134 | 135 | const consoleGoogle = () => { 136 | console.log('Login with google...') 137 | } 138 | 139 | const signGithub = () => { 140 | signInWithGithub(); 141 | } 142 | 143 | const consoleTwitter = () => { 144 | console.log('Login with twitter...') 145 | } 146 | 147 | const handleCallBack = useCallback( 148 | (stringFromChild: string) => { 149 | signInWithSocial(stringFromChild) 150 | }, 151 | [] 152 | ); 153 | 154 | const handlePasswordCallBack = useCallback( 155 | (passwordFromChild: string) => { 156 | setPassword(passwordFromChild) 157 | }, 158 | [] 159 | ); 160 | 161 | const Login = async () => { 162 | try { 163 | setLoading(true); 164 | const { data, error } = await supabaseClient.auth.signInWithPassword({ 165 | email, 166 | password, 167 | }) 168 | if (error) { 169 | toast({ 170 | title: 'Error', 171 | position: 'top', 172 | description: error.message, 173 | status: 'warning', 174 | duration: 5000, 175 | isClosable: true 176 | }); 177 | } else { 178 | setUser(data.user) 179 | setSession(data.session) 180 | navigate("/profile"); 181 | } 182 | } catch (err) { 183 | throw err; 184 | } finally { 185 | setEmail('') 186 | setPassword('') 187 | setLoading(false); 188 | } 189 | } 190 | 191 | const Register = async () => { 192 | try { 193 | setLoading(true); 194 | const { data, error } = await supabaseClient.auth.signUp({ 195 | email, 196 | password 197 | }) 198 | if (error) { 199 | toast({ 200 | title: 'Error', 201 | position: 'top', 202 | description: error.message, 203 | status: 'warning', 204 | duration: 5000, 205 | isClosable: true 206 | }); 207 | } else { 208 | toast({ 209 | title: 'Success', 210 | position: 'top', 211 | description: 'Account created', 212 | status: 'success', 213 | duration: 5000, 214 | isClosable: true 215 | }) 216 | navigate("/profile"); 217 | } 218 | } catch (err) { 219 | // console.log(err) 220 | throw err; 221 | } finally { 222 | setEmail('') 223 | setPassword('') 224 | setLoading(false); 225 | } 226 | 227 | } 228 | 229 | useEffect(() => { 230 | if (session) { 231 | console.log(session) 232 | } 233 | }, []) 234 | 235 | return ( 236 | 237 | 238 | 239 | 240 | 241 | {!authButtonState ? 'Register a new account' : 'Sign in to your account'} 242 | 243 | {authButtonState ? 'Don\'t have an account?' : 'Already a User?'} 244 | 247 | 248 | 249 | 250 | 256 | 257 | 258 | 259 | Username/Password 260 | Magic Link 261 | 262 | 263 | {/* initially mounted */} 264 | 265 | 266 | 267 | 268 | Email 269 | 270 | 271 | 272 | Password 273 | 274 | 275 | : } 279 | onClick={onClickReveal} 280 | /> 281 | 282 | 291 | 292 | 293 | 294 | 295 | 296 | 299 | 300 | 301 | 302 | or continue with 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | {/* initially not mounted */} 311 | 312 | 313 | 314 | Email address 315 | 316 | 317 | 318 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | ); 341 | }; 342 | 343 | export default Signin; 344 | -------------------------------------------------------------------------------- /frontend/src/components/LikeButton.css: -------------------------------------------------------------------------------- 1 | .button__badge { 2 | position: relative; 3 | top: -20px; 4 | left: 10px; 5 | 6 | background-color: rgba(212, 19, 13, 1); 7 | color: #fff; 8 | border-radius: 3px; 9 | padding: 1px 3px; 10 | font: 10px Verdana; 11 | z-index: 9999; 12 | } -------------------------------------------------------------------------------- /frontend/src/components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Stack, Tooltip } from '@chakra-ui/react'; 2 | import { HiOutlineThumbUp } from 'react-icons/hi'; 3 | import { LIKE_BUTTON_TEXT } from '../utils/constants'; 4 | import './LikeButton.css'; 5 | 6 | interface LikeButtonProps { 7 | isDisabled: boolean; 8 | onClick?: () => void; 9 | likesCount?: number; 10 | } 11 | 12 | const LikeButton = ({ isDisabled, onClick, likesCount }: LikeButtonProps) => { 13 | return ( 14 | 15 | 16 |
17 | {likesCount ? likesCount : 0} 18 | } onClick={onClick}/> 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default LikeButton; 26 | -------------------------------------------------------------------------------- /frontend/src/components/MainNavigation.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 2rem; 3 | } 4 | 5 | .header ul { 6 | display: flex; 7 | gap: 2rem; 8 | justify-content: center; 9 | } 10 | 11 | .header a { 12 | color: #e5e5e5; 13 | font-size: 1.15rem; 14 | text-decoration: none; 15 | } 16 | 17 | .header a:hover, 18 | .header a:active, 19 | .header a.active { 20 | color: #fcb66b; 21 | } -------------------------------------------------------------------------------- /frontend/src/components/MainNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon, CloseIcon, HamburgerIcon } from '@chakra-ui/icons'; 2 | import { 3 | Avatar, 4 | Box, 5 | Button, 6 | Flex, 7 | HStack, 8 | IconButton, 9 | Menu, 10 | MenuButton, 11 | MenuDivider, 12 | MenuItem, 13 | MenuList, 14 | Stack, 15 | Text, 16 | useColorModeValue, 17 | useDisclosure, 18 | VStack 19 | } from '@chakra-ui/react'; 20 | import { Session, User } from '@supabase/supabase-js'; 21 | import { AxiosResponse } from 'axios'; 22 | import { useEffect, useState } from 'react'; 23 | import { FaHome } from 'react-icons/fa'; 24 | import { FiLogOut } from 'react-icons/fi'; 25 | import { useQuery, useQueryClient } from 'react-query'; 26 | import { NavLink, useNavigate } from 'react-router-dom'; 27 | import { getPictureByProfileId, getProfileByAuthorEmail } from '../api'; 28 | import { ColorModeSwitcher } from '../ColorModeSwitcher'; 29 | import { supabaseClient } from '../config/supabase-client'; 30 | import eventBus from '../eventBus'; 31 | 32 | const MainNavigation = () => { 33 | const [user, setUser] = useState() 34 | const [session, setSession] = useState(); 35 | const [avatar_url, setAvatarUrl] = useState(); 36 | const [imageUrl, setImageUrl] = useState(); 37 | const [profile, setProfile] = useState() 38 | const { isOpen, onOpen, onClose } = useDisclosure(); 39 | const navigate = useNavigate(); 40 | const queryClient = useQueryClient(); 41 | 42 | useEffect(() => { 43 | // we listen here if someone cleans the storage in the browser 44 | // so we push him back and logout 45 | // check: https://stackoverflow.com/questions/56660153/how-to-listen-to-localstorage-value-changes-in-react 46 | const handleLocalStorage = () => { 47 | window.addEventListener('storage', (event) => { 48 | if (event) supabaseClient.auth.signOut() 49 | }) 50 | } 51 | //if (session) getAvatarUrl(); 52 | //if (session) refetch() 53 | handleLocalStorage() 54 | }, []); 55 | 56 | useEffect(() => { 57 | const setData = async () => { 58 | const { data: { session }, error } = await supabaseClient.auth.getSession(); 59 | if (error) throw error; 60 | if (session) { 61 | setSession(session) 62 | //setUser(session.user) 63 | } 64 | }; 65 | 66 | supabaseClient.auth.onAuthStateChange((_event, session) => { 67 | if (_event === 'SIGNED_OUT') { 68 | localStorage.removeItem('user'); 69 | } 70 | setSession(session); 71 | }); 72 | 73 | setData(); 74 | }, []); 75 | 76 | const fetchProfile = async () => { 77 | const res: AxiosResponse = await getProfileByAuthorEmail(session?.user?.email!) 78 | return res.data; 79 | }; 80 | 81 | const { data: profileData, error, isLoading: isFetchingProfile, refetch: refetchProfile } = useQuery('profile', fetchProfile, { 82 | enabled: false, onSuccess(res: IProfile) { 83 | setProfile(res) 84 | }, 85 | onError: (err) => { 86 | console.log(err) 87 | } 88 | }); 89 | 90 | const fetchProfilePicture = async () => { 91 | const res: AxiosResponse = await getPictureByProfileId(profile?.id!) 92 | return res.data 93 | } 94 | 95 | const { data: pictureData, isLoading, isError, refetch: refetchPicture } = useQuery('profilePicture', fetchProfilePicture, { 96 | enabled: false, retry: 2, cacheTime: 0, onSuccess(res: IPicture) { 97 | //setPicture(res) 98 | }, 99 | onError: (error: any) => { 100 | console.log(error) 101 | } 102 | }) 103 | 104 | useEffect(() => { 105 | 106 | if (avatar_url) downloadImage(avatar_url); 107 | }, [avatar_url]); 108 | 109 | async function downloadImage(path: any) { 110 | try { 111 | const { data, error }: any = await supabaseClient.storage.from('images').download(path); 112 | if (error) { 113 | throw error; 114 | } 115 | const url: any = URL.createObjectURL(data); 116 | setImageUrl(url); 117 | } catch (error: any) { 118 | console.log('Error downloading image: ', error.message); 119 | } 120 | } 121 | 122 | // we listen for potential ProfilePage.tsx updates especially avatar 123 | // and we reload the gravatar url 124 | eventBus.on('profileUpdated', (hasUpdated: boolean) => { 125 | if (hasUpdated) { 126 | refetchProfile() 127 | refetchPicture() 128 | } 129 | }); 130 | 131 | useEffect(() => { 132 | if (session?.user) { 133 | //console.log('user->', session.user.email) 134 | setUser(session.user) 135 | refetchProfile() 136 | } 137 | 138 | if (user) { 139 | 140 | //setProfile(profileData) 141 | setAvatarUrl(profile?.picture?.avatarUrl) 142 | } 143 | }, [session?.user, profile, user, refetchProfile]) 144 | 145 | const signOut = async () => { 146 | await supabaseClient.auth.signOut() 147 | setAvatarUrl('') 148 | navigate("/login"); 149 | } 150 | 151 | return ( 152 | <> 153 | 154 | 155 | : } 158 | aria-label={'Open Menu'} 159 | display={{ md: 'none' }} 160 | onClick={isOpen ? onClose : onOpen} 161 | /> 162 | 163 | DevConnector 🚀 164 | 168 | ({ 169 | color: isActive ? 'lightblue' : '', 170 | })} end> 171 | 174 | 175 | ({ color: isActive ? 'lightblue' : '' })} to="/invoices" end> 176 | Not Found 177 | 178 | ({ color: isActive ? 'lightblue' : '' })} end> 179 | Profiles 180 | 181 | ({ color: isActive ? 'lightblue' : '' })} end> 182 | Posts 183 | 184 | ({ color: isActive ? 'lightblue' : '' })} end> 185 | Profile 186 | 187 | 188 | 189 | 190 | {session ? ( 191 | <> 192 | 201 | 202 | 203 | 204 | 205 | 206 | {profile?.username} 207 | 208 | Admin 209 | 210 | 211 | 212 | 213 | 214 | ({ color: isActive ? 'lightblue' : '' })} end> 215 | Profile 216 | 217 | 218 | Settings 219 | Billing 220 | 221 | signOut}>Sign out 222 | 223 | 224 | 225 | ) : ( 226 | ({ color: isActive ? 'lightblue' : '' })} end> 227 | Login 228 | 229 | )} 230 | 231 | 232 | 233 | 234 | {isOpen ? ( 235 | 236 | 237 | ({ color: isActive ? 'lightblue' : '' })} end> 238 | 241 | 242 | ({ color: isActive ? 'lightblue' : '' })} end> 243 | Not Found 244 | 245 | ({ color: isActive ? 'lightblue' : '' })} end> 246 | Profiles 247 | 248 | ({ color: isActive ? 'lightblue' : '' })} end> 249 | Posts 250 | 251 | ({ color: isActive ? 'lightblue' : '' })} end> 252 | Profile 253 | 254 | 255 | 256 | ) : null} 257 | 258 | 259 | ); 260 | }; 261 | 262 | export default MainNavigation; 263 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Text, Button } from '@chakra-ui/react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | 13 | 404 14 | 15 | 16 | Page Not Found 17 | 18 | 19 | The page you're looking for does not seem to exist 20 | 21 | 22 | 29 | 30 | 31 | ); 32 | } -------------------------------------------------------------------------------- /frontend/src/components/PersonalAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Button, Flex, keyframes, Tooltip } from '@chakra-ui/react'; 2 | import { useEffect, useState } from 'react'; 3 | import { supabaseClient } from '../config/supabase-client'; 4 | import { UPLOAD_PICTURE_DISABLED_TEXT } from '../utils/constants'; 5 | 6 | const PersonalAvatar = ({ url, onUpload, disabled }: any) => { 7 | const [avatarUrl, setAvatarUrl] = useState(null); 8 | const [uploading, setUploading] = useState(false); 9 | 10 | const size = '96px'; 11 | const color = 'teal'; 12 | 13 | const pulseRing = keyframes` 14 | 0% { 15 | transform: scale(0.33); 16 | } 17 | 40%, 18 | 50% { 19 | opacity: 0; 20 | } 21 | 100% { 22 | opacity: 0; 23 | } 24 | `; 25 | 26 | useEffect(() => { 27 | if (url) downloadImage(url); 28 | }, [url]); 29 | 30 | async function downloadImage(path: any) { 31 | try { 32 | const { data, error }: any = await supabaseClient.storage.from('images').download(path); 33 | if (error) { 34 | throw error; 35 | } 36 | const url: any = URL.createObjectURL(data); 37 | setAvatarUrl(url); 38 | } catch (error: any) { 39 | console.log('Error downloading image: ', error.message); 40 | } 41 | } 42 | 43 | async function uploadAvatar(event: any) { 44 | try { 45 | setUploading(true); 46 | 47 | if (!event.target.files || event.target.files.length === 0) { 48 | throw new Error('You must select an image to upload.'); 49 | } 50 | 51 | const file = event.target.files[0]; 52 | const fileExt = file.name.split('.').pop(); 53 | const fileName = `${Math.random()}.${fileExt}`; 54 | const filePath = `${fileName}`; 55 | 56 | let { error: uploadError } = await supabaseClient.storage.from('images').upload(filePath, file); 57 | 58 | if (uploadError) { 59 | throw uploadError; 60 | } 61 | 62 | onUpload(filePath); 63 | } catch (error: any) { 64 | alert(error.message); 65 | } finally { 66 | setUploading(false); 67 | } 68 | } 69 | 70 | return ( 71 | <> 72 | 73 | 91 | 92 | 93 | 94 | 95 | 96 | 110 | 111 | 123 | 124 | 125 | ); 126 | }; 127 | 128 | export default PersonalAvatar; 129 | -------------------------------------------------------------------------------- /frontend/src/components/PostActions.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon } from '@chakra-ui/icons'; 2 | import { Box, Button, Flex, useColorModeValue } from '@chakra-ui/react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | const PostActions = () => { 6 | return ( 7 | 8 | 9 | 10 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default PostActions; -------------------------------------------------------------------------------- /frontend/src/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { 3 | Avatar, 4 | Box, 5 | Center, 6 | Container, 7 | Divider, 8 | Flex, 9 | Progress, 10 | Stack, 11 | Text, 12 | useColorModeValue, 13 | useToast 14 | } from '@chakra-ui/react'; 15 | import moment from 'moment'; 16 | import { truncate } from '../utils/functions'; 17 | import { ReadmoreButton } from './ReadMoreButton'; 18 | import LikeButton from './LikeButton'; 19 | import ProfileAvatar from './ProfileAvatar'; 20 | 21 | function Posts({ posts }: any) { 22 | const color = useColorModeValue('white', 'gray.900'); 23 | 24 | return ( 25 | 26 | 27 | {posts.map(({ id, createdAt, title, content, profile, likes }: any, i: number) => ( 28 |
29 | 30 | 31 | {title} 32 | 33 | {content} 34 | 35 | 36 | 37 | 38 | {profile.authorEmail} 39 | {moment(createdAt).format('Do MMMM YYYY')} 40 | 41 | 42 | 43 | {/* 44 | {me && author.id === me.id && } 45 | */} 46 | 47 | 48 | 49 | 50 | 51 | {/* 52 | {comments?.length} 53 | */} 54 | {/* */} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | ))} 65 |
66 |
67 | ); 68 | } 69 | 70 | export default Posts; 71 | -------------------------------------------------------------------------------- /frontend/src/components/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { EditIcon } from '@chakra-ui/icons'; 2 | import { 3 | Box, Progress, useToast, 4 | Heading, 5 | Text, 6 | Stack, 7 | Button, 8 | Badge, 9 | useColorModeValue, 10 | Flex, 11 | Input, 12 | FormControl, 13 | FormLabel, 14 | Tag, 15 | TagLabel, 16 | useDisclosure, 17 | AlertDialog, 18 | AlertDialogOverlay, 19 | AlertDialogContent, 20 | AlertDialogHeader, 21 | AlertDialogBody, 22 | AlertDialogFooter, 23 | HStack 24 | } from '@chakra-ui/react'; 25 | import { Session, User } from '@supabase/supabase-js'; 26 | import { AxiosResponse } from 'axios'; 27 | import { useEffect, useRef, useState } from 'react'; 28 | import { useMutation, useQuery } from 'react-query'; 29 | import { supabaseClient } from '../config/supabase-client'; 30 | import { getRandomColor } from '../utils/functions'; 31 | import PersonalAvatar from './PersonalAvatar'; 32 | import { AsyncSelect, MultiValue } from 'chakra-react-select'; 33 | import { pickListOptions } from '../config/pickListOptions'; 34 | import { FaAddressBook, FaCheck } from 'react-icons/fa'; 35 | import eventBus from '../eventBus'; 36 | import { createPicture, getPictureByProfileId, getProfileByAuthorEmail, updatePicture, createProfile, saveProfile, publishProfile } from '../api'; 37 | 38 | const mappedColourOptions = pickListOptions.map(option => ({ 39 | ...option, 40 | colorScheme: option.color 41 | })); 42 | 43 | const Profile = () => { 44 | const [session, setSession] = useState(); 45 | const [profile, setProfile] = useState() 46 | const [picture, setPicture] = useState() 47 | const [avatarUrl, setAvatarUrl] = useState(); 48 | const [username, setUsername] = useState(); 49 | const [website, setWebsite] = useState(); 50 | const [company, setCompany] = useState(); 51 | const [profileId, setProfileId] = useState() 52 | const [authorEmail, setAuthorEmail] = useState(); 53 | const [user, setUser] = useState() 54 | const [isEditingLanguage, setIsEditingLanguage] = useState(); 55 | const [isUrlUploaded, setIsUrlUploaded] = useState(); 56 | const [isPublic, setIsPublic] = useState(); 57 | const [languages, setLanguages] = useState(); 58 | const [newParams, setNewParams] = useState([]); 59 | const toast = useToast(); 60 | const { isOpen, onOpen, onClose } = useDisclosure() 61 | const cancelRef = useRef(null); 62 | 63 | const color3 = useColorModeValue('gray.50', 'gray.800') 64 | const color4 = useColorModeValue('white', 'gray.700') 65 | 66 | useEffect(() => { 67 | const setData = async () => { 68 | const { data: { session }, error } = await supabaseClient.auth.getSession(); 69 | if (error) throw error; 70 | setSession(session); 71 | //console.log('session from App', session?.access_token) 72 | if (session) { 73 | setUser(session.user) 74 | } 75 | }; 76 | 77 | setData(); 78 | }, []); 79 | 80 | const fetchProfilePicture = async () => { 81 | const res: AxiosResponse = await getPictureByProfileId(profile?.id!) 82 | return res.data 83 | } 84 | 85 | const { data: pictureData, isLoading, isError, refetch: refetchPicture } = useQuery(['profilePicture'], fetchProfilePicture, { 86 | enabled: false, retry: 2, cacheTime: 0, onSuccess(res: IPicture) { 87 | setPicture(res) 88 | }, 89 | onError: (error: any) => { 90 | toast({ 91 | title: 'Error', 92 | position: 'top', 93 | variant: 'subtle', 94 | description: error, 95 | status: 'error', 96 | duration: 3000, 97 | isClosable: true 98 | }); 99 | } 100 | }) 101 | 102 | const fetchProfile = async () => { 103 | const res: AxiosResponse = await getProfileByAuthorEmail(user?.email!) 104 | return res.data; 105 | }; 106 | 107 | const { data: profileData, error: profileError, isLoading: isFetchingProfile, refetch: refetchProfile } = useQuery(['profile'], fetchProfile, { 108 | enabled: false, retry: 2, cacheTime: 0, onSuccess(res: IProfile) { 109 | setProfile(res) 110 | if (res != null) { 111 | setUsername(res.username) 112 | setWebsite(res.website) 113 | setCompany(res.company) 114 | setProfileId(res.id) 115 | setIsPublic(res.isPublic) 116 | setAuthorEmail(res.authorEmail) 117 | if (res.programmingLanguages.length !== newParams.length) { 118 | res.programmingLanguages.forEach(obj => { 119 | newParams.push(obj.language) 120 | }) 121 | } 122 | setLanguages(newParams) 123 | setIsEditingLanguage(false) 124 | } else { 125 | setIsEditingLanguage(true) 126 | } 127 | }, 128 | onError: (error: any) => { 129 | toast({ 130 | title: 'Error', 131 | position: 'top', 132 | variant: 'subtle', 133 | description: error, 134 | status: 'error', 135 | duration: 3000, 136 | isClosable: true 137 | }); 138 | } 139 | }); 140 | 141 | const postCreateProfile = async (): Promise => { 142 | const profile: Omit = { 143 | website: website!, 144 | username: username!, 145 | company: company!, 146 | authorEmail: user?.email!, 147 | programmingLanguages: languages! 148 | }; 149 | return await createProfile(profile); 150 | } 151 | 152 | const { isLoading: isCreatingProfile, mutate: postProfile } = useMutation(postCreateProfile, { 153 | onSuccess(res) { 154 | toast({ 155 | title: 'Profile created.', 156 | position: 'top', 157 | variant: 'subtle', 158 | description: '', 159 | status: 'success', 160 | duration: 3000, 161 | isClosable: true 162 | }); 163 | refetchProfile() 164 | } 165 | }); 166 | 167 | const postUpdateProfile = async (): Promise => { 168 | const profile: IProfile = { 169 | website: website!, 170 | username: username!, 171 | company: company!, 172 | authorEmail: user?.email!, 173 | id: profileId!, 174 | programmingLanguages: languages! 175 | }; 176 | return await saveProfile(profile); 177 | } 178 | 179 | const { isLoading: isUpdatingProfile, mutate: updateProfile } = useMutation( 180 | postUpdateProfile, 181 | { 182 | onSuccess: (res) => { 183 | toast({ 184 | title: 'Profile updated.', 185 | position: 'top', 186 | variant: 'subtle', 187 | description: '', 188 | status: 'success', 189 | duration: 3000, 190 | isClosable: true 191 | }); 192 | refetchProfile() 193 | }, 194 | onError: (err) => { 195 | console.log(err) 196 | }, 197 | //onMutate: () => console.log('mutating') 198 | } 199 | ); 200 | 201 | const postPublishProfile = async (): Promise => { 202 | return await publishProfile(profileId!); 203 | } 204 | 205 | const { isLoading: isPublishingProfile, mutate: publish } = useMutation( 206 | postPublishProfile, 207 | { 208 | onSuccess: (res) => { 209 | toast({ 210 | title: 'Profile published.', 211 | position: 'top', 212 | variant: 'subtle', 213 | description: '', 214 | status: 'success', 215 | duration: 3000, 216 | isClosable: true 217 | }); 218 | refetchProfile() 219 | }, 220 | onError: (err) => { 221 | console.log(err) 222 | }, 223 | //onMutate: () => console.log('mutating') 224 | } 225 | ); 226 | 227 | const postCreateProfilePicture = async (): Promise => { 228 | const picture: Omit = { 229 | profileId: profileId!, 230 | avatarUrl: avatarUrl! 231 | }; 232 | return await createPicture(picture, session?.access_token!); 233 | } 234 | 235 | const { isLoading: isCreatingProfileUrl, mutate: createProfilePicture } = useMutation( 236 | postCreateProfilePicture, 237 | { 238 | onSuccess: (res) => { 239 | toast({ 240 | title: 'Picture created.', 241 | position: 'top', 242 | variant: 'subtle', 243 | description: '', 244 | status: 'success', 245 | duration: 3000, 246 | isClosable: true 247 | }); 248 | eventBus.dispatch('profileUpdated', true); 249 | }, 250 | onError: (err: any) => { 251 | toast({ 252 | title: 'Error uploading picture', 253 | position: 'top', 254 | variant: 'subtle', 255 | description: err.response.data.error, 256 | status: 'error', 257 | duration: 3000, 258 | isClosable: true 259 | }); 260 | }, 261 | } 262 | ); 263 | 264 | const postUpdateProfilePicture = async (): Promise => { 265 | const picture: Omit = { 266 | profileId: profileId!, 267 | avatarUrl: avatarUrl! 268 | }; 269 | return await updatePicture(picture, session?.access_token!); 270 | } 271 | 272 | const { isLoading: isUpdatingProfileUrl, mutate: updateProfilePicture } = useMutation( 273 | postUpdateProfilePicture, 274 | { 275 | onSuccess: (res) => { 276 | toast({ 277 | title: 'Picture updated.', 278 | position: 'top', 279 | variant: 'subtle', 280 | description: '', 281 | status: 'success', 282 | duration: 3000, 283 | isClosable: true 284 | }); 285 | eventBus.dispatch('profileUpdated', true); 286 | }, 287 | onError: (err) => { 288 | console.log(err) 289 | }, 290 | } 291 | ); 292 | 293 | useEffect(() => { 294 | if (user) { 295 | //console.log('user->', user) 296 | refetchProfile() 297 | } 298 | if (profile) { 299 | //console.log('prof', profile) 300 | refetchPicture() 301 | } 302 | if (picture) { 303 | //console.log('pic pic', picture) 304 | } 305 | 306 | }, [user, refetchProfile, profile, refetchPicture]) 307 | 308 | useEffect(() => { 309 | 310 | if (isUrlUploaded) { 311 | handleProfilePicture() 312 | } 313 | }, [isUrlUploaded]) 314 | 315 | async function handleProfilePicture() { 316 | try { 317 | picture?.id ? updateProfilePicture() : createProfilePicture(); 318 | } catch (error: any) { 319 | alert(error.message); 320 | } 321 | } 322 | 323 | function publishMe() { 324 | onClose() 325 | publish(); 326 | } 327 | 328 | function postData() { 329 | try { 330 | if (profileId) { 331 | updateProfile() 332 | } else { 333 | postProfile() 334 | } 335 | } catch (err) { 336 | //setPostResult(fortmatResponse(err)); 337 | } 338 | } 339 | 340 | const editLanguage = () => { 341 | setNewParams([]) 342 | setIsEditingLanguage(true) 343 | } 344 | 345 | function handleLanguages(e: MultiValue<{ colorScheme: string; value: string; label: string; color: string; }>) { 346 | let newParams: any[] = [] 347 | for (let i = 0; i < e.length; i += 1) { 348 | const obje = e[i].value 349 | newParams.push(obje) 350 | } 351 | 352 | setLanguages(newParams) 353 | } 354 | 355 | if (isFetchingProfile) return 356 | 357 | return ( 358 | 361 | 370 | 371 | User Profile Edit 372 | 373 | 374 | { 378 | setAvatarUrl(url); 379 | setIsUrlUploaded(true) 380 | }} 381 | /> 382 | 383 | 384 | {session?.user.email} 385 | 386 | 387 | {isPublic ? `Public` : `Private`} 388 | 389 | 390 | 391 | 392 | username 393 | setUsername(e.target.value)} 398 | /> 399 | 400 | 401 | website 402 | setWebsite(e.target.value)} 407 | /> 408 | 409 | 410 | company 411 | setCompany(e.target.value)} 416 | /> 417 | 418 | {isEditingLanguage ? ( 419 | Select programming languages that you like most 420 | handleLanguages(e)} 422 | isMulti 423 | name="colors" 424 | options={mappedColourOptions} 425 | placeholder="ex: Java, GoLang" 426 | closeMenuOnSelect={false} 427 | size="md" 428 | loadOptions={(inputValue, callback) => { 429 | setTimeout(() => { 430 | const values = mappedColourOptions.filter((i) => 431 | i.label.toLowerCase().includes(inputValue.toLowerCase()), 432 | ); 433 | callback(values); 434 | }, 3000); 435 | }} 436 | /> 437 | ) : ( 438 | <> 439 | Programming languages 440 | 441 | {Object.entries(newParams) 442 | .map( 443 | ([key, value]) => ({value}) 444 | ) 445 | } 446 | 449 | 450 | )} 451 | 452 | {!isPublic && profileId && } 465 | 470 | 471 | 472 | 473 | Publish Profile 474 | 475 | 476 | 477 | Are you sure? You can't undo this action afterwards. 478 | 479 | 480 | 481 | 484 | 491 | 492 | 493 | 494 | 495 | 509 | 510 | 511 | 512 | ); 513 | } 514 | 515 | export default Profile -------------------------------------------------------------------------------- /frontend/src/components/ProfileAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@chakra-ui/react'; 2 | import { useEffect, useState } from 'react'; 3 | import { supabaseClient } from '../config/supabase-client'; 4 | 5 | interface ProfileAvatarProps { 6 | avatarName: string | undefined; 7 | url: string | undefined; 8 | avatarSize?: string; 9 | } 10 | 11 | const ProfileAvatar = ({ url, avatarName, avatarSize }: ProfileAvatarProps) => { 12 | const [avatarUrl, setAvatarUrl] = useState(); 13 | 14 | useEffect(() => { 15 | if (url) downloadImage(url); 16 | }, [url]); 17 | 18 | async function downloadImage(path: any) { 19 | try { 20 | const { data, error }: any = await supabaseClient.storage.from('images').download(path); 21 | if (error) { 22 | throw error; 23 | } 24 | const url: any = URL.createObjectURL(data); 25 | setAvatarUrl(url); 26 | } catch (error: any) { 27 | console.log('Error downloading image: ', error.message); 28 | } 29 | } 30 | 31 | return ; 32 | }; 33 | 34 | export default ProfileAvatar; 35 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Box, Button, Container, FormControl, FormLabel, HStack, Input, Progress, Stack, Tag, TagLabel, Text, useColorModeValue, useDisclosure, useToast } from '@chakra-ui/react'; 2 | import { User } from '@supabase/supabase-js'; 3 | import { AxiosResponse } from 'axios'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | import { useMutation, useQuery } from 'react-query'; 6 | import { createProfile, getProfileByAuthorEmail, publishProfile, saveProfile } from '../api'; 7 | import { supabaseClient } from '../config/supabase-client'; 8 | import { EditIcon } from '@chakra-ui/icons' 9 | import { FaAddressBook, FaCheck } from 'react-icons/fa'; 10 | import { AsyncSelect, MultiValue } from 'chakra-react-select'; 11 | import { pickListOptions } from '../config/pickListOptions'; 12 | import { getRandomColor } from '../utils/functions'; 13 | 14 | const mappedColourOptions = pickListOptions.map(option => ({ 15 | ...option, 16 | colorScheme: option.color 17 | })); 18 | 19 | interface Props { 20 | isPublished?: boolean; 21 | childToParent(success: boolean): any; 22 | } 23 | 24 | const ProfileDetail = ({ childToParent }: Props) => { 25 | const [username, setUsername] = useState(''); 26 | const [languages, setLanguages] = useState(); 27 | const [website, setWebsite] = useState(''); 28 | const [company, setCompany] = useState(''); 29 | const [isPublic, setIsPublic] = useState(); 30 | const [isEditingLanguage, setIsEditingLanguage] = useState(); 31 | const [profileId, setProfileId] = useState() 32 | const [user, setUser] = useState(); 33 | const [newParams, setNewParams] = useState([]); 34 | 35 | const toast = useToast(); 36 | const { isOpen, onOpen, onClose } = useDisclosure() 37 | const cancelRef = useRef(null); 38 | 39 | useEffect(() => { 40 | // declare the data fetching function 41 | const fetchUserData = async () => { 42 | const { data: { user } } = await supabaseClient.auth.getUser() 43 | setUser(user) 44 | 45 | // we refetch here? 46 | //if (user) refetch() 47 | } 48 | // call the function 49 | fetchUserData() 50 | // make sure to catch any error 51 | .catch(console.error); 52 | }, []) 53 | 54 | const fetchProfile = async () => { 55 | const res: AxiosResponse = await getProfileByAuthorEmail(user?.email!) 56 | return res.data; 57 | }; 58 | 59 | const { isLoading: isFetchingProfile, data: profileData, refetch } = useQuery(['profile'], fetchProfile, { 60 | enabled: false, onSuccess(res: IProfile) { 61 | if (res != null) { 62 | setUsername(res.username) 63 | setWebsite(res.website) 64 | setCompany(res.company) 65 | setProfileId(res.id) 66 | childToParent(res.isPublic ? true : false); 67 | setIsPublic(res.isPublic) 68 | 69 | if (res.programmingLanguages.length !== newParams.length) { 70 | res.programmingLanguages.forEach(obj => { 71 | newParams.push(obj.language) 72 | }) 73 | } 74 | setLanguages(newParams) 75 | setIsEditingLanguage(false) 76 | } else { 77 | setIsEditingLanguage(true) 78 | } 79 | }, 80 | onError: (error) => { 81 | console.log(error) 82 | }, 83 | }); 84 | 85 | const postCreateProfile = async (): Promise => { 86 | const profile: Omit = { 87 | website: website, 88 | username: username, 89 | company: company, 90 | authorEmail: user?.email!, 91 | programmingLanguages: languages! 92 | }; 93 | return await createProfile(profile); 94 | } 95 | 96 | const { isLoading: isCreatingProfile, mutate: postProfile } = useMutation(postCreateProfile, { 97 | onSuccess(res) { 98 | toast({ 99 | title: 'Profile created.', 100 | position: 'top', 101 | variant: 'subtle', 102 | description: '', 103 | status: 'success', 104 | duration: 3000, 105 | isClosable: true 106 | }); 107 | refetch() 108 | } 109 | }); 110 | 111 | const postUpdateProfile = async (): Promise => { 112 | const profile: IProfile = { 113 | website: website, 114 | username: username, 115 | company: company, 116 | authorEmail: user?.email!, 117 | id: profileId!, 118 | programmingLanguages: languages! 119 | }; 120 | return await saveProfile(profile); 121 | } 122 | 123 | const { isLoading: isUpdatingProfile, mutate: updateProfile } = useMutation( 124 | postUpdateProfile, 125 | { 126 | onSuccess: (res) => { 127 | toast({ 128 | title: 'Profile updated.', 129 | position: 'top', 130 | variant: 'subtle', 131 | description: '', 132 | status: 'success', 133 | duration: 3000, 134 | isClosable: true 135 | }); 136 | refetch() 137 | }, 138 | onError: (err) => { 139 | console.log(err) 140 | }, 141 | //onMutate: () => console.log('mutating') 142 | } 143 | ); 144 | 145 | const postPublishProfile = async (): Promise => { 146 | return await publishProfile(profileId!); 147 | } 148 | 149 | const { isLoading: isPublishingProfile, mutate: publish } = useMutation( 150 | postPublishProfile, 151 | { 152 | onSuccess: (res) => { 153 | toast({ 154 | title: 'Profile published.', 155 | position: 'top', 156 | variant: 'subtle', 157 | description: '', 158 | status: 'success', 159 | duration: 3000, 160 | isClosable: true 161 | }); 162 | refetch() 163 | }, 164 | onError: (err) => { 165 | console.log(err) 166 | }, 167 | //onMutate: () => console.log('mutating') 168 | } 169 | ); 170 | 171 | function publishMe() { 172 | onClose() 173 | publish(); 174 | } 175 | 176 | function postData() { 177 | try { 178 | if (profileId) { 179 | updateProfile() 180 | } else { 181 | postProfile() 182 | } 183 | } catch (err) { 184 | //setPostResult(fortmatResponse(err)); 185 | } 186 | } 187 | 188 | function handleLanguages(e: MultiValue<{ colorScheme: string; value: string; label: string; color: string; }>) { 189 | let newParams: any[] = [] 190 | for (let i = 0; i < e.length; i += 1) { 191 | const obje = e[i].value 192 | newParams.push(obje) 193 | } 194 | 195 | setLanguages(newParams) 196 | } 197 | 198 | const editLanguage = () => { 199 | setNewParams([]) 200 | setIsEditingLanguage(true) 201 | } 202 | 203 | function handleUserNameChange(e: any) { 204 | setUsername(e.target.value); 205 | } 206 | 207 | const color = useColorModeValue('gray.800', 'gray.200') 208 | const bgColor = useColorModeValue('gray.100', 'gray.600') 209 | const bgColorFocus = useColorModeValue('gray.200', 'gray.800') 210 | 211 | return ( 212 | 213 | 214 | 215 | refetch()}> 216 | 217 | Show profile 218 | 219 | 220 | 221 | 222 | 223 | 224 | Username 225 | setUsername(e.target.value)} 229 | placeholder={username || 'username'} 230 | color={color} 231 | bg={bgColor} 232 | rounded={'full'} 233 | border={0} 234 | _focus={{ 235 | bg: bgColorFocus, 236 | outline: 'none' 237 | }} 238 | /> 239 | 240 | 241 | 242 | 243 | Website 244 | setWebsite(e.target.value)} 248 | placeholder={website || 'website'} 249 | color={color} 250 | bg={bgColor} 251 | rounded={'full'} 252 | border={0} 253 | _focus={{ 254 | bg: bgColorFocus, 255 | outline: 'none' 256 | }} 257 | /> 258 | 259 | 260 | 261 | 262 | Company 263 | setCompany(e.target.value)} 267 | placeholder={company || 'company'} 268 | color={color} 269 | bg={bgColor} 270 | rounded={'full'} 271 | border={0} 272 | _focus={{ 273 | bg: bgColorFocus, 274 | outline: 'none' 275 | }} 276 | /> 277 | 278 | 279 | 280 | {isEditingLanguage ? ( 281 | Select programming languages that you like most 282 | handleLanguages(e)} 284 | isMulti 285 | name="colors" 286 | options={mappedColourOptions} 287 | placeholder="ex: Java, GoLang" 288 | closeMenuOnSelect={false} 289 | size="md" 290 | loadOptions={(inputValue, callback) => { 291 | setTimeout(() => { 292 | const values = mappedColourOptions.filter((i) => 293 | i.label.toLowerCase().includes(inputValue.toLowerCase()), 294 | ); 295 | callback(values); 296 | }, 3000); 297 | }} 298 | /> 299 | ) : ( 300 | <> 301 | Programming languages 302 | 303 | {Object.entries(newParams) 304 | .map( 305 | ([key, value]) => ({value}) 306 | ) 307 | } 308 | 311 | )} 312 | 313 | 314 | {!isPublic && profileId && } 327 | 332 | 333 | 334 | 335 | Publish Profile 336 | 337 | 338 | 339 | Are you sure? You can't undo this action afterwards. 340 | 341 | 342 | 343 | 346 | 353 | 354 | 355 | 356 | 357 | 371 | 372 | 373 | 374 | 375 | 376 | ) 377 | }; 378 | 379 | export default ProfileDetail; 380 | -------------------------------------------------------------------------------- /frontend/src/components/Profiles.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Heading, 3 | Avatar, 4 | Box, 5 | Text, 6 | Stack, 7 | Button, 8 | useColorModeValue, 9 | Card, 10 | CardHeader, 11 | Flex, 12 | IconButton, 13 | CardBody, 14 | CardFooter, 15 | Container, 16 | SimpleGrid, 17 | Tag 18 | } from '@chakra-ui/react'; 19 | import { BsThreeDotsVertical } from 'react-icons/bs'; 20 | import { truncate } from '../utils/functions'; 21 | import ProfileAvatar from './ProfileAvatar'; 22 | 23 | const Profiles = ({ profiles }: any) => { 24 | 25 | const followColor = useColorModeValue('gray.400', 'gray.900'); 26 | 27 | return ( 28 | 29 | 30 | {profiles.map(({ username, company, authorEmail, website, programmingLanguages, picture }: IProfile, i: number) => ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {username} - {authorEmail} 40 | 41 | 42 | {company} - {website} 43 | 44 | 45 | 46 | } /> 47 | 48 | 49 | 50 | 51 | With Chakra UI, I wanted to sync the speed of development with the speed of design. I wanted the 52 | developer to be just as excited as the designer to create a screen. 53 | 54 | {programmingLanguages.map((value, index) => ( 55 | 56 | {value.language} 57 | 58 | ))} 59 | 60 | 61 | button': { 66 | minW: '136px' 67 | } 68 | }}> 69 | 81 | 82 | 83 | 84 | ))} 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default Profiles; 91 | -------------------------------------------------------------------------------- /frontend/src/components/ProfilesActions.tsx: -------------------------------------------------------------------------------- 1 | import { AddIcon } from '@chakra-ui/icons'; 2 | import { Box, Button, Flex, useColorModeValue } from '@chakra-ui/react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | const ProfilesActions = () => { 6 | return ( 7 | 8 | 9 | 10 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default ProfilesActions; -------------------------------------------------------------------------------- /frontend/src/components/ProfilesLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import ProfilesActions from './ProfilesActions'; 3 | 4 | const ProfilesLayout = () => { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default ProfilesLayout; -------------------------------------------------------------------------------- /frontend/src/components/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | 3 | const ProtectedRoute = ({ children, signedIn }: ProtectedRouteProps) => { 4 | 5 | if (!signedIn) { 6 | // user is not authenticated 7 | return ; 8 | } 9 | return <>{children} 10 | }; 11 | 12 | export default ProtectedRoute -------------------------------------------------------------------------------- /frontend/src/components/ReadMoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Stack } from '@chakra-ui/react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export const ReadmoreButton = ({ postId }: ReadMoreButtonProps) => { 5 | return ( 6 | 7 | 8 |
9 | 25 |
26 |
27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/src/components/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import MainNavigation from "./MainNavigation" 3 | 4 | const RootLayout = ({children}: {children: ReactNode}) => { 5 | return ( 6 | <> 7 | 8 |
{children}
9 | 10 | ) 11 | } 12 | 13 | export default RootLayout -------------------------------------------------------------------------------- /frontend/src/config/pickListOptions.ts: -------------------------------------------------------------------------------- 1 | export const pickListOptions = [ 2 | { value: 'python', label: 'Python', color: '' }, 3 | { value: 'typescript', label: 'TypeScript', color: '' }, 4 | { value: 'javascript', label: 'JavaScript', color: '' }, 5 | { value: 'ruby', label: 'Ruby', color: '' }, 6 | { value: 'java', label: 'Java', color: '' }, 7 | { value: 'php', label: 'PHP', color: '' }, 8 | { value: 'golang', label: 'GoLang', color: '' }, 9 | { value: 'c++', label: 'C++', color: '' }, 10 | { value: 'c#', label: 'C#', color: '' }, 11 | { value: 'clojure', label: 'Clojure', color: '' }, 12 | { value: 'scala', label: 'Scala', color: '' }, 13 | { value: 'kotlin', label: 'Kotlin', color: '' }, 14 | { value: 'solidity', label: 'Solidity', color: '' }, 15 | { value: 'rust', label: 'Rust', color: '' }, 16 | { value: '.net', label: '.NET', color: '' } 17 | ]; 18 | 19 | pickListOptions.forEach(obj => { 20 | let letters = ['red', 'green', 'blue', 'orange', 'yellow', 'purple', 'pink', 'cyan']; 21 | let color = ''; 22 | for (let i = 0; i < 8; i++) { 23 | color = letters[Math.floor(Math.random() * letters.length)]; 24 | } 25 | obj.color = color; 26 | }); 27 | -------------------------------------------------------------------------------- /frontend/src/config/supabase-client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseUrl: string = process.env.REACT_APP_SUPABASE_URL!; 4 | const supabaseAnonKey: string = process.env.REACT_APP_SUPABASE_ANON_KEY!; 5 | 6 | export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey); 7 | -------------------------------------------------------------------------------- /frontend/src/eventBus.ts: -------------------------------------------------------------------------------- 1 | const eventBus = { 2 | on(event: any, callback: any) { 3 | document.addEventListener(event, (e) => callback(e.detail)); 4 | }, 5 | dispatch(event: any, data: any) { 6 | document.dispatchEvent(new CustomEvent(event, { detail: data })); 7 | }, 8 | remove(event: any, callback: any) { 9 | document.removeEventListener(event, callback); 10 | }, 11 | }; 12 | 13 | export default eventBus; -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ColorModeScript } from "@chakra-ui/react" 2 | import * as React from "react" 3 | import * as ReactDOM from "react-dom/client" 4 | import { App } from "./App" 5 | import reportWebVitals from "./reportWebVitals" 6 | import * as serviceWorker from "./serviceWorker" 7 | 8 | import { QueryClient, QueryClientProvider } from "react-query"; 9 | 10 | const container = document.getElementById("root") 11 | if (!container) throw new Error('Failed to find the root element'); 12 | const root = ReactDOM.createRoot(container) 13 | 14 | const queryClient = new QueryClient(); 15 | root.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | ) 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: https://cra.link/PWA 28 | serviceWorker.unregister() 29 | 30 | // If you want to start measuring performance in your app, pass a function 31 | // to log results (for example: reportWebVitals(console.log)) 32 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 33 | reportWebVitals() 34 | 35 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/pages/NewPostPage.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { supabaseClient } from '../config/supabase-client'; 4 | import { useMutation, useQuery } from 'react-query'; 5 | import { AxiosResponse } from 'axios'; 6 | import { addPost, getProfileByAuthorEmail } from '../api'; 7 | import { Session, User } from '@supabase/supabase-js'; 8 | import { Button, Container, Flex, FormControl, Heading, Input, Stack, useColorModeValue, useToast } from '@chakra-ui/react'; 9 | import { CheckIcon } from '@chakra-ui/icons'; 10 | 11 | function NewPostPage() { 12 | const [postTitle, setPostTitle] = useState(""); 13 | const [postContent, setPostContent] = useState(""); 14 | const [session, setSession] = useState(); 15 | const [user, setUser] = useState(); 16 | const [state, setState] = useState<'initial' | 'submitting' | 'success'>('initial'); 17 | const [error, setError] = useState(false); 18 | const toast = useToast(); 19 | const navigate = useNavigate() 20 | const [profile, setProfile] = useState() 21 | 22 | const fetchProfile = async () => { 23 | const res: AxiosResponse = await getProfileByAuthorEmail(session?.user.email!) 24 | return res.data; 25 | }; 26 | 27 | const { data: profileData, isLoading: isFetchingProfile, refetch } = useQuery(['profile'], fetchProfile, { 28 | enabled: false, onSuccess(res: IProfile) { 29 | }, 30 | onError: (err) => { 31 | console.log(err) 32 | } 33 | }); 34 | 35 | useEffect(() => { 36 | const setData = async () => { 37 | const { data: { session }, error } = await supabaseClient.auth.getSession(); 38 | if (error) throw error; 39 | setSession(session); 40 | // console.log(JSON.stringify(session?.access_token)) 41 | }; 42 | 43 | setData(); 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (profileData) { 48 | setProfile(profileData) 49 | } 50 | 51 | // declare the data fetching function 52 | const fetchUserData = async () => { 53 | const { data: { user } } = await supabaseClient.auth.getUser() 54 | setUser(user) 55 | } 56 | 57 | // call the function 58 | fetchUserData() 59 | // make sure to catch any error 60 | .catch(console.error); 61 | }, [profileData]) 62 | 63 | const createPost = async (): Promise => { 64 | const post: Omit = { 65 | title: postTitle, 66 | content: postContent, 67 | profileId: profile?.id!, 68 | } 69 | return await addPost(post, session?.access_token!); 70 | } 71 | 72 | const { isLoading: isPostingTutorial, mutate: postTutorial } = useMutation(createPost, { 73 | onSuccess(res) { 74 | toast({ 75 | title: 'Post created.', 76 | position: 'top', 77 | variant: 'subtle', 78 | description: '', 79 | status: 'success', 80 | duration: 3000, 81 | isClosable: true 82 | }); 83 | } 84 | }) 85 | 86 | function postData() { 87 | try { 88 | postTutorial() 89 | } catch (err) { 90 | //setPostResult(fortmatResponse(err)); 91 | } 92 | } 93 | 94 | return ( 95 | 96 | 103 | 104 | Wha do you have in mind? 105 | 106 | { 111 | e.preventDefault(); 112 | 113 | try { 114 | if (postTitle.length < 1 || postContent.length < 1) { 115 | setError(true); 116 | toast({ 117 | position: 'top', 118 | title: 'An error occured', 119 | description: `${error}`, 120 | status: 'error', 121 | duration: 5000, 122 | isClosable: true 123 | }); 124 | return; 125 | } 126 | } catch (error) { 127 | toast({ 128 | position: 'top', 129 | title: 'An error occured', 130 | description: `${error}`, 131 | duration: 5000, 132 | status: 'error', 133 | isClosable: true 134 | }); 135 | } 136 | 137 | setError(false); 138 | setState('submitting'); 139 | 140 | setTimeout(() => { 141 | setState('success'); 142 | }, 1000); 143 | setTimeout(() => { 144 | navigate('/posts') 145 | }, 2000); 146 | }} 147 | > 148 | 149 | ) => setPostTitle(e.target.value)} 163 | > 164 | 165 | 166 | ) => setPostContent(e.target.value)} 180 | > 181 | 182 | 183 | 192 | 193 | 194 | 195 | 196 | ); 197 | } 198 | 199 | export default NewPostPage; 200 | -------------------------------------------------------------------------------- /frontend/src/pages/PostDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Center, 4 | Heading, 5 | Text, 6 | Stack, 7 | Avatar, 8 | useColorModeValue, 9 | Spinner, 10 | Image, 11 | Flex, 12 | useToast, 13 | } from '@chakra-ui/react'; 14 | import { Session } from '@supabase/supabase-js'; 15 | 16 | import { AxiosResponse } from 'axios'; 17 | import moment from 'moment'; 18 | import { useState, useEffect } from 'react'; 19 | import { useMutation, useQuery } from 'react-query'; 20 | import { useParams } from 'react-router-dom'; 21 | import { addLike, getPost } from '../api'; 22 | import LikeButton from '../components/LikeButton'; 23 | import ProfileAvatar from '../components/ProfileAvatar'; 24 | 25 | interface Props { 26 | session: Session | null | undefined 27 | } 28 | 29 | const PostDetailPage = ({ session }: Props) => { 30 | const color = useColorModeValue('white', 'gray.900'); 31 | const color2 = useColorModeValue('gray.700', 'white'); 32 | const toast = useToast(); 33 | 34 | const [post, setPost] = useState(); 35 | const params = useParams(); 36 | const { id } = params; 37 | 38 | const postAddLike = async (): Promise => { 39 | const like: Omit = { 40 | postId: post?.id!, 41 | profileId: post?.profileId! 42 | }; 43 | console.log(like) 44 | return await addLike(like, session?.access_token!); 45 | } 46 | 47 | const { isLoading: isPostingTutorial, mutate: postLike } = useMutation(postAddLike, { 48 | onSuccess(res) { 49 | //console.log('liked', res) 50 | toast({ 51 | title: 'Liked.', 52 | position: 'top', 53 | variant: 'subtle', 54 | description: '', 55 | status: 'success', 56 | duration: 3000, 57 | isClosable: true 58 | }); 59 | refetch() 60 | }, 61 | onError: (err) => { 62 | console.log(err) 63 | } 64 | }) 65 | 66 | const fetchPost = async (): Promise => getPost(Number(id)) 67 | 68 | const { data, error, isError, isLoading, refetch } = useQuery('post', fetchPost, { 69 | enabled: true, retry: 2, cacheTime: 0, onSuccess(res: any) { 70 | setPost(res.data) 71 | }, 72 | onError: (error: any) => { 73 | console.log(error) 74 | }, 75 | }) 76 | 77 | if (isLoading) { 78 | return 79 | } 80 | 81 | if (isError) { 82 | return
Error! {(error as Error).message}
83 | } 84 | 85 | function handleLikeCallback(): void { 86 | postLike() 87 | } 88 | 89 | return ( 90 | 91 | 92 |
93 | 94 | 95 | 101 | Blog 102 | 103 | 107 | {post?.title} 108 | 109 | 110 | {post?.content} 111 | 112 | 113 | 114 | 115 | 116 | {post?.profile?.authorEmail} 117 | {moment(post?.createdAt).format('Do MMMM YYYY')} 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 |
127 | ); 128 | } 129 | 130 | export default PostDetailPage; -------------------------------------------------------------------------------- /frontend/src/pages/PostLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import PostActions from '../components/PostActions'; 3 | 4 | const PostLayout = () => { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default PostLayout; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/PostPage.tsx: -------------------------------------------------------------------------------- 1 | import { fetchPosts } from '../api'; 2 | import { useQuery } from 'react-query' 3 | 4 | import Posts from '../components/Posts'; 5 | import { Progress } from '@chakra-ui/react'; 6 | 7 | function BlogPostsPage() { 8 | const { data, error, isError, isLoading } = useQuery('posts', fetchPosts) 9 | 10 | if (isLoading) { 11 | return 12 | } 13 | if (isError) { 14 | return
Error! {(error as Error).message}
15 | } 16 | 17 | return ( 18 | <> 19 | {isLoading &&

Loading posts...

} 20 | {error &&

{error}

} 21 | {!error && data && } 22 | 23 | ); 24 | } 25 | 26 | export default BlogPostsPage; -------------------------------------------------------------------------------- /frontend/src/pages/ProfileLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | 3 | const ProfileLayout = () => { 4 | return ( 5 | <> 6 | 7 | 8 | ); 9 | }; 10 | 11 | export default ProfileLayout; 12 | -------------------------------------------------------------------------------- /frontend/src/pages/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Box, 4 | Progress, 5 | Text, 6 | useToast 7 | } from '@chakra-ui/react'; 8 | import { Session, } from '@supabase/supabase-js'; 9 | import { useCallback, useEffect, useState } from 'react'; 10 | import PersonalAvatar from '../components/PersonalAvatar'; 11 | import { supabaseClient } from '../config/supabase-client'; 12 | import eventBus from '../eventBus'; 13 | import ProfileDetail from '../components/ProfileDetail'; 14 | import { AxiosResponse } from 'axios'; 15 | import { createPicture, getPictureByProfileId, getProfileByAuthorEmail, updatePicture } from '../api'; 16 | import { useMutation, useQuery } from 'react-query'; 17 | 18 | const ProfilePage = () => { 19 | const [session, setSession] = useState(); 20 | const [avatarUrl, setAvatarUrl] = useState(''); 21 | const toast = useToast(); 22 | const [isPublic, setIsPublic] = useState(); 23 | const [isUrlUploaded, setIsUrlUploaded] = useState(); 24 | const [profileId, setProfileId] = useState() 25 | const [profile, setProfile] = useState() 26 | const [picture, setPicture] = useState() 27 | 28 | const fetchProfile = async () => { 29 | const res: AxiosResponse = await getProfileByAuthorEmail(session?.user?.email!) 30 | return res.data; 31 | }; 32 | 33 | const { data: profileData, error: profileError, isLoading: isFetchingProfile, refetch: refetchProfile } = useQuery(['profile'], fetchProfile, { 34 | enabled: true, retry: 2, cacheTime: 0, onSuccess(res: IProfile) { 35 | }, 36 | onError: (error: any) => { 37 | console.log(error) 38 | } 39 | }); 40 | 41 | const fetchProfilePicture = async () => { 42 | const res: AxiosResponse = await getPictureByProfileId(profile?.id!) 43 | return res.data 44 | } 45 | 46 | const { data: pictureData, isLoading, isError, refetch } = useQuery(['profilePicture'], fetchProfilePicture, { 47 | enabled: false, onSuccess(res: IPicture) { 48 | }, 49 | onError: (error) => { 50 | console.log(error) 51 | } 52 | }) 53 | 54 | useEffect(() => { 55 | const setData = async () => { 56 | const { data: { session }, error } = await supabaseClient.auth.getSession(); 57 | if (error) throw error; 58 | setSession(session); 59 | }; 60 | 61 | setData(); 62 | 63 | if (profileData) { 64 | setProfile(profileData) 65 | setProfileId(profile?.id) 66 | } 67 | if (pictureData) { 68 | setPicture(pictureData) 69 | } 70 | }, [profileData, pictureData]) 71 | 72 | useEffect(() => { 73 | 74 | if (isUrlUploaded) { 75 | updateProfile() 76 | } 77 | }, [isUrlUploaded]) 78 | 79 | const postUpdateProfileUrl = async (): Promise => { 80 | const picture: Omit = { 81 | profileId: profileId!, 82 | avatarUrl: avatarUrl! 83 | }; 84 | return await updatePicture(picture, session?.access_token!); 85 | } 86 | 87 | const { isLoading: isUpdatingProfileUrl, mutate: updateProfileUrl } = useMutation( 88 | postUpdateProfileUrl, 89 | { 90 | onSuccess: (res) => { 91 | toast({ 92 | title: 'Picture updated.', 93 | position: 'top', 94 | variant: 'subtle', 95 | description: '', 96 | status: 'success', 97 | duration: 3000, 98 | isClosable: true 99 | }); 100 | eventBus.dispatch('profileUpdated', true); 101 | }, 102 | onError: (err) => { 103 | console.log(err) 104 | }, 105 | } 106 | ); 107 | 108 | const postCreateProfileUrl = async (): Promise => { 109 | const picture: Omit = { 110 | profileId: profileId!, 111 | avatarUrl: avatarUrl! 112 | }; 113 | return await createPicture(picture, session?.access_token!); 114 | } 115 | 116 | const { isLoading: isCreatingProfileUrl, mutate: createProfileUrl } = useMutation( 117 | postCreateProfileUrl, 118 | { 119 | onSuccess: (res) => { 120 | toast({ 121 | title: 'Picture created.', 122 | position: 'top', 123 | variant: 'subtle', 124 | description: '', 125 | status: 'success', 126 | duration: 3000, 127 | isClosable: true 128 | }); 129 | eventBus.dispatch('profileUpdated', true); 130 | }, 131 | onError: (err: any) => { 132 | toast({ 133 | title: 'Error uploading picture', 134 | position: 'top', 135 | variant: 'subtle', 136 | description: err.response.data.error, 137 | status: 'error', 138 | duration: 3000, 139 | isClosable: true 140 | }); 141 | }, 142 | } 143 | ); 144 | 145 | async function updateProfile() { 146 | try { 147 | picture?.id ? updateProfileUrl() : createProfileUrl(); 148 | } catch (error: any) { 149 | alert(error.message); 150 | } 151 | } 152 | 153 | const handleCallBack = useCallback( 154 | (booleanFromChild: boolean) => { 155 | setIsPublic(booleanFromChild) 156 | }, 157 | [] 158 | ); 159 | 160 | if (isFetchingProfile) return 161 | 162 | return ( 163 |
164 | { 168 | setAvatarUrl(url); 169 | setIsUrlUploaded(true) 170 | }} 171 | /> 172 | 173 | 174 | {session?.user?.email} 175 | 176 | 177 | {isPublic ? `Public` : `Private`} 178 | 179 | 180 | 181 |
182 | ) 183 | }; 184 | 185 | export default ProfilePage; 186 | -------------------------------------------------------------------------------- /frontend/src/pages/ProfilesPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { getProfiles } from "../api"; 3 | import { useQuery } from 'react-query'; 4 | import Profiles from '../components/Profiles'; 5 | import { Progress } from "@chakra-ui/react"; 6 | 7 | const ProfilesPage = () => { 8 | const [profiles, setProfiles] = useState() 9 | 10 | const fetchProfiles = async () => { 11 | const res = await getProfiles() 12 | return res 13 | }; 14 | 15 | const { data: profilesData, error, isError, isLoading } = useQuery('profiles', fetchProfiles, { 16 | enabled: true, retry: 2, cacheTime: 0, onSuccess(res: any) { 17 | //console.log(res) 18 | }, 19 | onError: (error: any) => { 20 | console.log(error) 21 | }, 22 | //initialData: () => [] 23 | }) 24 | 25 | useEffect(() => { 26 | 27 | if (profilesData) { 28 | setProfiles(profilesData) 29 | } 30 | 31 | }, [profilesData]) 32 | 33 | if (isLoading) { 34 | return 35 | } 36 | if (isError) { 37 | return
Error! {(error as Error).message}
38 | } 39 | 40 | return ( 41 | <> 42 | {isLoading &&

Loading posts...

} 43 | {error &&

{error}

} 44 | {!error && profiles && } 45 | 46 | ) 47 | } 48 | 49 | export default ProfilesPage 50 | 51 | -------------------------------------------------------------------------------- /frontend/src/pages/WelcomePage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Stack, 3 | Flex, 4 | Button, 5 | Text, 6 | VStack, 7 | useBreakpointValue, 8 | } from '@chakra-ui/react'; 9 | 10 | const WelcomePage = () => { 11 | 12 | return ( 13 | 21 | 26 | 27 | 32 | DevConnector V4 🚀 33 | 34 | 35 | 42 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default WelcomePage -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals" 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://cra.link/PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 20 | ), 21 | ) 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void 26 | } 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 32 | if (publicUrl.origin !== window.location.origin) { 33 | // Our service worker won't work if PUBLIC_URL is on a different origin 34 | // from what our page is served on. This might happen if a CDN is used to 35 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 36 | return 37 | } 38 | 39 | window.addEventListener("load", () => { 40 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 41 | 42 | if (isLocalhost) { 43 | // This is running on localhost. Let's check if a service worker still exists or not. 44 | checkValidServiceWorker(swUrl, config) 45 | 46 | // Add some additional logging to localhost, pointing developers to the 47 | // service worker/PWA documentation. 48 | navigator.serviceWorker.ready.then(() => { 49 | console.log( 50 | "This web app is being served cache-first by a service " + 51 | "worker. To learn more, visit https://cra.link/PWA", 52 | ) 53 | }) 54 | } else { 55 | // Is not localhost. Just register service worker 56 | registerValidSW(swUrl, config) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | function registerValidSW(swUrl: string, config?: Config) { 63 | navigator.serviceWorker 64 | .register(swUrl) 65 | .then((registration) => { 66 | registration.onupdatefound = () => { 67 | const installingWorker = registration.installing 68 | if (installingWorker == null) { 69 | return 70 | } 71 | installingWorker.onstatechange = () => { 72 | if (installingWorker.state === "installed") { 73 | if (navigator.serviceWorker.controller) { 74 | // At this point, the updated precached content has been fetched, 75 | // but the previous service worker will still serve the older 76 | // content until all client tabs are closed. 77 | console.log( 78 | "New content is available and will be used when all " + 79 | "tabs for this page are closed. See https://cra.link/PWA.", 80 | ) 81 | 82 | // Execute callback 83 | if (config && config.onUpdate) { 84 | config.onUpdate(registration) 85 | } 86 | } else { 87 | // At this point, everything has been precached. 88 | // It is the perfect time to display a 89 | // "Content is cached for offline use." message. 90 | console.log("Content is cached for offline use.") 91 | 92 | // Execute callback 93 | if (config && config.onSuccess) { 94 | config.onSuccess(registration) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | }) 101 | .catch((error) => { 102 | console.error("Error during service worker registration:", error) 103 | }) 104 | } 105 | 106 | function checkValidServiceWorker(swUrl: string, config?: Config) { 107 | // Check if the service worker can be found. If it can't reload the page. 108 | fetch(swUrl, { 109 | headers: { "Service-Worker": "script" }, 110 | }) 111 | .then((response) => { 112 | // Ensure service worker exists, and that we really are getting a JS file. 113 | const contentType = response.headers.get("content-type") 114 | if ( 115 | response.status === 404 || 116 | (contentType != null && contentType.indexOf("javascript") === -1) 117 | ) { 118 | // No service worker found. Probably a different app. Reload the page. 119 | navigator.serviceWorker.ready.then((registration) => { 120 | registration.unregister().then(() => { 121 | window.location.reload() 122 | }) 123 | }) 124 | } else { 125 | // Service worker found. Proceed as normal. 126 | registerValidSW(swUrl, config) 127 | } 128 | }) 129 | .catch(() => { 130 | console.log( 131 | "No internet connection found. App is running in offline mode.", 132 | ) 133 | }) 134 | } 135 | 136 | export function unregister() { 137 | if ("serviceWorker" in navigator) { 138 | navigator.serviceWorker.ready 139 | .then((registration) => { 140 | registration.unregister() 141 | }) 142 | .catch((error) => { 143 | console.error(error.message) 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom" 6 | -------------------------------------------------------------------------------- /frontend/src/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { render, RenderOptions } from "@testing-library/react" 3 | import { ChakraProvider, theme } from "@chakra-ui/react" 4 | 5 | const AllProviders = ({ children }: { children?: React.ReactNode }) => ( 6 | {children} 7 | ) 8 | 9 | const customRender = (ui: React.ReactElement, options?: RenderOptions) => 10 | render(ui, { wrapper: AllProviders, ...options }) 11 | 12 | export { customRender as render } 13 | -------------------------------------------------------------------------------- /frontend/src/types/type.d.ts: -------------------------------------------------------------------------------- 1 | interface IPost { 2 | id: number; 3 | title: string; 4 | content: string; 5 | status?: boolean; 6 | createdAt?: string; 7 | updatedAt?: string; 8 | accToken?: string; 9 | profileId: number; 10 | profile?: IProfile; 11 | likes?: ILike[]; 12 | } 13 | 14 | interface IProgrammingLanguage { 15 | createdAt?: string; 16 | id: number; 17 | language: string; 18 | profileId: number; 19 | color?: string; 20 | } 21 | 22 | interface IPicture { 23 | id: number; 24 | profileId: number; 25 | avatarUrl: string; 26 | createdAt?: string; 27 | updatedAt?: string; 28 | accToken?: string; 29 | } 30 | 31 | interface ILike { 32 | id: number; 33 | profileId: number; 34 | postId: number; 35 | createdAt?: string; 36 | accToken?: string; 37 | } 38 | 39 | interface IProfile { 40 | id: number; 41 | authorEmail: string; 42 | website: string; 43 | username: string; 44 | company: string; 45 | status?: boolean; 46 | createdAt?: string; 47 | updatedAt?: string; 48 | isPublic?: boolean; 49 | picture?: IPicture; 50 | programmingLanguages: IProgrammingLanguage[]; 51 | } 52 | 53 | interface TodoProps { 54 | todo: IPost; 55 | } 56 | 57 | type ApiDataType = { 58 | message: string; 59 | status: string; 60 | posts?: IPost[]; 61 | todo?: IPost; 62 | profile?: IProfile 63 | picture?: IPicture 64 | profiles: IProfile[] 65 | }; 66 | 67 | type GetPostsResponse = { 68 | posts: IPost[]; 69 | }; 70 | 71 | interface ProtectedRouteProps { 72 | children: ReactNode; 73 | signedIn: boolean; 74 | } 75 | 76 | interface ProfilePageProps { 77 | childToParent?: boolean; 78 | } 79 | 80 | interface ReadMoreButtonProps { 81 | postId: number; 82 | } -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DISABLED_PROFILE_TEXT = 'you must be logged in to see your profile'; 2 | export const DELETE_BUTTON_TEXT = 'You can delete this post'; 3 | export const LIKE_BUTTON_TEXT = 'Smash the like button!'; 4 | export const UPLOAD_PICTURE_DISABLED_TEXT = 'You must create your profile first' 5 | export const regex = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i; -------------------------------------------------------------------------------- /frontend/src/utils/functions.ts: -------------------------------------------------------------------------------- 1 | export const truncate = (str: string) => { 2 | return str.length > 1 ? str.substring(0, 1) + '' : str; 3 | }; 4 | 5 | export const getRandomColor = (): string => { 6 | let letters = ['red', 'green', 'blue', 'orange', 'yellow', 'purple', 'pink', 'cyan']; 7 | let color = ''; 8 | for (let i = 0; i < 8; i++) { 9 | color = letters[Math.floor(Math.random() * letters.length)]; 10 | } 11 | return color 12 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniellaera/supabase-react-auth/7cf0838a7e0641731e403195aafbac909c001ebc/thumbnail.png --------------------------------------------------------------------------------