├── .gitignore ├── LICENSE ├── README.md ├── apps ├── backend │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc.json │ ├── package.json │ ├── prisma │ │ └── schema.prisma │ ├── src │ │ ├── db │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── routes │ │ │ ├── auth.ts │ │ │ ├── insights.ts │ │ │ ├── sessionInstance.ts │ │ │ ├── sessionSchema.ts │ │ │ └── workout.ts │ │ ├── utils │ │ │ ├── authMiddlewares.ts │ │ │ ├── breakUserFullName.ts │ │ │ ├── handleSocialSignInData.ts │ │ │ ├── oauthHelpers.ts │ │ │ ├── sendErrorResponse.ts │ │ │ └── setAuthTokenAsCookie.ts │ │ └── validators │ │ │ ├── auth.ts │ │ │ ├── insights.ts │ │ │ ├── isValidUUID.ts │ │ │ ├── sessionInstance.ts │ │ │ ├── sessionSchema.ts │ │ │ └── workout.ts │ └── tsconfig.json ├── commons │ ├── .eslintrc.js │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── prismaGenTypes.ts │ │ ├── requestTypes │ │ │ ├── insights.ts │ │ │ ├── sessionInstance.ts │ │ │ ├── sessionSchema.ts │ │ │ └── workout.ts │ │ └── responseTypes │ │ │ ├── auth.ts │ │ │ ├── insights.ts │ │ │ ├── sessionInstance.ts │ │ │ ├── sessionSchema.ts │ │ │ └── workout.ts │ └── tsconfig.json └── frontend │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── api.ts │ ├── components │ ├── CreateNewOrEditWorkoutModal.tsx │ ├── CustomMenuIcon.tsx │ ├── ErrorMessageAlertBox.tsx │ ├── ErrorScreen.tsx │ ├── Footer.tsx │ ├── FormSingleSelectField.tsx │ ├── FormSingleSelectFieldWithCreatable.tsx │ ├── FormikTextAreaField.tsx │ ├── Forms │ │ ├── RenderSupersetBlockForm.tsx │ │ ├── RenderWorkoutForm.tsx │ │ └── SessionSchemaForm.tsx │ ├── Head.tsx │ ├── InputField.tsx │ ├── Logo.tsx │ ├── LogoWithoutBeta.tsx │ ├── MultiCreateInput.tsx │ ├── Navbar.tsx │ ├── PhoneMock.tsx │ ├── RenderWorkoutView.tsx │ ├── RenderWorkoutsList.tsx │ ├── SessionSchemaView.tsx │ ├── SigmaModal.tsx │ ├── TimeSpentChart.tsx │ └── icons │ │ ├── AnalyticsIcon.tsx │ │ ├── CustomXIcon.tsx │ │ ├── DashboardIcon.tsx │ │ ├── DropsetArrow.tsx │ │ ├── GitHubIcon.tsx │ │ ├── GoogleIcon.tsx │ │ ├── MoveGrabber.tsx │ │ ├── ServerErrorIllustration.tsx │ │ ├── SigmaFitLogoHead.tsx │ │ ├── TopWorkoutRoutinesIcon.tsx │ │ ├── TwitterIcon.tsx │ │ └── WorkoutIcon.tsx │ ├── hooks │ ├── useScript.tsx │ ├── withAuthHOC.tsx │ └── withNoAuthHOC.tsx │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── auth │ │ ├── logout.tsx │ │ └── welcome.tsx │ ├── dash.tsx │ ├── index.tsx │ ├── insights.tsx │ ├── profile.tsx │ ├── sessionInstance │ │ └── [id].tsx │ ├── sessionSchema │ │ ├── [id] │ │ │ ├── clone.tsx │ │ │ ├── edit.tsx │ │ │ └── view.tsx │ │ ├── new.tsx │ │ └── top.tsx │ └── workout │ │ └── index.tsx │ ├── postcss.config.js │ ├── public │ ├── assets │ │ └── arrow.svg │ ├── browserconfig.xml │ ├── favicon.ico │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg │ ├── manifest.json │ └── mocks │ │ ├── 0.png │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ ├── 7.png │ │ ├── 8.png │ │ ├── 9.png │ │ └── pwa.png │ ├── styles │ └── globals.css │ ├── tailwind.config.js │ └── tsconfig.json ├── package.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | README-backup.md 2 | **/node_modules/ 3 | workouts.sql 4 | slots/ 5 | backup/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SigmaFit 2 | 3 | ![sigmafit landing page](https://cdn.hashnode.com/res/hashnode/image/upload/v1659292859167/kWsp9nXgW.png) 4 | 5 | ## ⚡️ Features 6 | 1. **Track Everything**: Log all your workouts on SigmaFit. It's simpler and more rugged than any notes-taking app out there. It will help you with the planning, execution and tracking of progress. 7 | 2. **Wide support**: SigmaFit lets you log the thing you want to track. You can track distance, duration, weight, reps, and anything based on your workout requirements. 8 | 3. **Fully customizable**: In SigmaFit, it's super easy to get started with community Training Routines. You can build your custom workout plan too. SigmaFit is super customizable, and creating a training routine is a cakewalk. 9 | 4. **Record History**: Personal Records are not just any numbers. They're **very special** for every athlete out there. SigmaFit keeps track of it and provides extra motivation to make it even bigger! 10 | 5. **Sharing is caring**: If you've created a training routine you're particularly proud of, you can share it with the community and friends. 11 | 6. **Available on all devices**: SigmaFit is a progressive web app. You can run it on any browser environment. It takes lesser resources and is blazingly fast 🚀. 12 | 7. **Superb easy onboarding**: SigmaFit offers convenient ways of signing in using social auth like Google, Twitter and GitHub. No need to memorize another password for our platform. 13 | 14 | 15 | ## 💼 Entity Relationship diagram 16 | 17 | I really like planning before taking the first step. So, after finalizing the features to include in **SigmaFit MVP**, I started working on the Entity Relationship Diagram (ERD). Here is the ERD built using [draw.io](https://draw.io): 18 | 19 | ![sigmafit-erd](https://cdn.hashnode.com/res/hashnode/image/upload/v1659297111873/Iwi8hkte4.png) 20 | 21 | 22 | ## 📟 Tech Stack 23 | Sigma is built using the following technologies. 24 | 25 | ![sigmafit-tech-stack](https://cdn.hashnode.com/res/hashnode/image/upload/v1659295075365/PXl2omKHj.png) 26 | 27 | 28 | ## 🥹 Quick Demo Video: [Link](https://vimeo.com/735293152) 29 | 30 | 31 | ## ⏭ What's Next 32 | 1. In the next version, we shall allow people to join friend circles to track each other's progress. It will keep everyone motivated to do physical activity and stay healthy. 33 | 2. In future, we shall also add offline support. (Currently, some functionalities can work offline 🤫, but there is no logic to fix the synchronization conflicts 😢). 34 | 35 | 36 |
37 | 38 | Powered by [Hashnode](https://hashnode.com/) and [Planetscale](https://planetscale.com/). 39 | -------------------------------------------------------------------------------- /apps/backend/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=x 2 | 3 | JSON_WEB_TOKEN_SECRET=x 4 | 5 | # CLIENT_URL is not necessary 6 | CLIENT_URL='/' 7 | 8 | # used by cookie secure flag 9 | USE_SECURE_COOKIE=1 10 | 11 | # server listen port 12 | PORT=8000 13 | -------------------------------------------------------------------------------- /apps/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | }, 6 | ignorePatterns: ["dist/**"], 7 | extends: ["prettier"], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaVersion: "latest", 11 | sourceType: "module", 12 | }, 13 | plugins: ["@typescript-eslint"], 14 | rules: { 15 | "max-len": 1, 16 | 17 | "max-len": ["error", { code: 180 }], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /apps/backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | dist/ 5 | -------------------------------------------------------------------------------- /apps/backend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | prisma/ 4 | -------------------------------------------------------------------------------- /apps/backend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sigmafit/backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "watch": "tsc -w", 8 | "start": "node dist/index.js", 9 | "dev": "nodemon src/index.ts", 10 | "prettier": "prettier --write .", 11 | "build": "tsc --build", 12 | "generate:prisma": "prisma generate", 13 | "lint": "eslint . && tsc --noEmit", 14 | "format": "prettier --write ." 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@prisma/client": "^3.15.2", 21 | "axios": "^0.27.2", 22 | "bcrypt": "^5.0.1", 23 | "cookie-parser": "^1.4.6", 24 | "cors": "^2.8.5", 25 | "crypto-js": "^4.1.1", 26 | "dotenv": "^16.0.1", 27 | "express": "^4.18.1", 28 | "google-auth-library": "^8.1.1", 29 | "jsonwebtoken": "^8.5.1", 30 | "oauth": "^0.10.0", 31 | "uuid": "^8.3.2", 32 | "yup": "^0.32.11" 33 | }, 34 | "devDependencies": { 35 | "@sigmafit/commons": "1.0.0", 36 | "@types/bcrypt": "^5.0.0", 37 | "@types/cookie-parser": "^1.4.3", 38 | "@types/cors": "^2.8.12", 39 | "@types/crypto-js": "^4.1.1", 40 | "@types/express": "^4.17.13", 41 | "@types/jsonwebtoken": "^8.5.8", 42 | "@types/oauth": "^0.9.1", 43 | "@types/uuid": "^8.3.4", 44 | "eslint": "^8.19.0", 45 | "eslint-config-prettier": "^8.5.0", 46 | "nodemon": "^2.0.16", 47 | "prettier": "2.7.1", 48 | "prisma": "^3.15.2", 49 | "ts-node": "^10.8.1", 50 | "typescript": "^4.7.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["referentialIntegrity", "interactiveTransactions"] 4 | } 5 | 6 | datasource db { 7 | provider = "mysql" 8 | url = env("DATABASE_URL") 9 | referentialIntegrity = "prisma" 10 | } 11 | 12 | model user { 13 | id String @id @db.VarChar(36) // since UUID is 36 chars long 14 | first_name String 15 | last_name String 16 | picture String 17 | email String @unique 18 | created_time DateTime @default(now()) 19 | last_token_generated_at DateTime @default(now()) 20 | is_google_connected Boolean @default(false) 21 | is_github_connected Boolean @default(false) 22 | is_twitter_connected Boolean @default(false) 23 | workout workout[] 24 | session_schema session_schema[] 25 | session_schema_vote_by_user session_schema_vote_by_user[] 26 | } 27 | 28 | enum workout_type { 29 | WEIGHT_AND_REPS 30 | REPS 31 | DISTANCE_AND_DURATION 32 | DURATION 33 | } 34 | 35 | enum body_part { 36 | ABS 37 | BICEPS 38 | TRICEPS 39 | BACK 40 | CARDIO 41 | CHEST 42 | CORE 43 | FOREARMS 44 | FULL_BODY 45 | LEGS 46 | CALFS 47 | SHOULDERS 48 | TRAPS 49 | OTHERS 50 | } 51 | 52 | enum intensity_levels { 53 | VERY_HARD 54 | HARD 55 | MEDIUM 56 | EASY 57 | WARMUP 58 | } 59 | 60 | model workout { 61 | id String @id @db.VarChar(36) // since UUID is 36 chars long 62 | name String 63 | category workout_type 64 | target_body_part body_part? 65 | workout_image_url String @default("https://images.unsplash.com/photo-1597076545399-91a3ff0e71b3?ixlib=rb-1.2.1&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb") 66 | intensity intensity_levels? 67 | owner_id String 68 | owner user @relation(fields: [owner_id], references: [id]) 69 | is_public Boolean @default(false) // is_true can be set only internally 70 | workout_schema workout_schema[] 71 | superset_workout_schema superset_workout_schema[] 72 | notes String @default("") // we can manage detailed notes on how to perform the exercise 73 | } 74 | 75 | enum schema_state { 76 | PRIVATE 77 | PUBLIC 78 | REVIEW 79 | } 80 | 81 | model session_schema { 82 | id String @id @db.VarChar(36) // since UUID is 36 chars long 83 | name String 84 | owner_id String 85 | owner user @relation(fields: [owner_id], references: [id], onDelete: Restrict) 86 | workout_schema workout_schema[] 87 | superset_schema superset_schema[] 88 | session_instance session_instance[] 89 | state schema_state @default(PRIVATE) 90 | votes_count Int @default(0) 91 | session_schema_vote_by_user session_schema_vote_by_user[] 92 | number_of_workouts Int 93 | number_of_superset_workouts Int 94 | number_of_workouts_in_superset Int 95 | } 96 | 97 | model session_schema_vote_by_user { 98 | user_id String 99 | user user @relation(fields: [user_id], references: [id], onDelete: Restrict) 100 | 101 | session_schema_id String 102 | session_schema session_schema @relation(fields: [session_schema_id], references: [id], onDelete: Restrict) 103 | 104 | voted_at DateTime 105 | 106 | @@id([user_id, session_schema_id]) 107 | } 108 | 109 | model workout_schema { 110 | id String @id @db.VarChar(36) // since UUID is 36 chars long 111 | session_schema_id String 112 | session_schema session_schema @relation(fields: [session_schema_id], references: [id], onDelete: Restrict) 113 | workout_id String 114 | workout workout @relation(fields: [workout_id], references: [id], onDelete: Restrict) 115 | default_target Json 116 | order Int @default(0) 117 | workout_instance workout_instance[] 118 | } 119 | 120 | model superset_schema { 121 | id String @id @db.VarChar(36) // since UUID is 36 chars long 122 | name String 123 | session_schema_id String 124 | session_schema session_schema @relation(fields: [session_schema_id], references: [id], onDelete: Restrict) 125 | order Int 126 | superset_workout_schema superset_workout_schema[] 127 | } 128 | 129 | model superset_workout_schema { 130 | id String @id @db.VarChar(36) // since UUID is 36 chars long 131 | superset_schema_id String 132 | superset_schema superset_schema @relation(fields: [superset_schema_id], references: [id], onDelete: Restrict) 133 | workout_id String 134 | workout workout @relation(fields: [workout_id], references: [id], onDelete: Restrict) 135 | default_target Json 136 | order Int @default(0) 137 | superset_workout_instance superset_workout_instance[] 138 | } 139 | 140 | model session_instance { 141 | id String @id @db.VarChar(36) // since UUID is 36 chars long 142 | session_schema_id String 143 | session_schema session_schema @relation(fields: [session_schema_id], references: [id], onDelete: Restrict) 144 | start_timestamp DateTime @default(now()) 145 | end_timestamp DateTime? 146 | workout_instance workout_instance[] 147 | superset_workout_instance superset_workout_instance[] 148 | } 149 | 150 | model workout_instance { 151 | workout_schema_id String 152 | workout_schema workout_schema @relation(fields: [workout_schema_id], references: [id]) 153 | session_instance_id String 154 | session_instance session_instance @relation(fields: [session_instance_id], references: [id]) 155 | sets_data Json 156 | 157 | @@unique([session_instance_id, workout_schema_id]) 158 | } 159 | 160 | model superset_workout_instance { 161 | superset_workout_schema_id String 162 | superset_workout_schema superset_workout_schema @relation(fields: [superset_workout_schema_id], references: [id]) 163 | session_instance_id String 164 | session_instance session_instance @relation(fields: [session_instance_id], references: [id]) 165 | sets_data Json 166 | 167 | @@unique([session_instance_id, superset_workout_schema_id]) 168 | } 169 | -------------------------------------------------------------------------------- /apps/backend/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | // we are having a single object which can be used across all routes 4 | export const prisma = new PrismaClient(); 5 | -------------------------------------------------------------------------------- /apps/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import Express from "express"; 2 | import authRouter from "./routes/auth"; 3 | import workoutRouter from "./routes/workout"; 4 | import sessionSchemaRouter from "./routes/sessionSchema"; 5 | import sessionInstanceRouter from "./routes/sessionInstance"; 6 | import insightsRouter from "./routes/insights"; 7 | import cookieParser from "cookie-parser"; 8 | import { jwtUserPayloadType } from "./utils/setAuthTokenAsCookie"; 9 | import cors from "cors"; 10 | 11 | const app = Express(); 12 | 13 | app.set("trust proxy", 1); 14 | 15 | app.use(Express.json()); 16 | app.use(cookieParser()); 17 | 18 | app.use( 19 | cors({ 20 | origin: (process.env.CLIENT_URL as string) ?? "/", 21 | credentials: true, 22 | }) 23 | ); 24 | // TODO: Cors 25 | 26 | declare global { 27 | namespace Express { 28 | interface Request { 29 | user: jwtUserPayloadType & { iat: number }; 30 | } 31 | } 32 | } 33 | 34 | app.use("/api/auth/", authRouter); 35 | app.use("/api/workout/", workoutRouter); 36 | app.use("/api/sessionSchema/", sessionSchemaRouter); 37 | app.use("/api/sessionInstance/", sessionInstanceRouter); 38 | app.use("/api/insights/", insightsRouter); 39 | 40 | app.all("*", (req, res) => 41 | res.status(400).send({ 42 | error: true, 43 | message: `Invalid attempt to ${req.method} ${req.path}`, 44 | }) 45 | ); 46 | 47 | const port = process.env.PORT || 8000; 48 | app.listen(port, async () => { 49 | console.log(`listening at ${port}`); 50 | }); 51 | -------------------------------------------------------------------------------- /apps/backend/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | isAuthenticated, 4 | isAuthenticatedWithoutErr, 5 | } from "../utils/authMiddlewares"; 6 | import { OAuth2Client } from "google-auth-library"; 7 | import jwt from "jsonwebtoken"; 8 | import axios from "axios"; 9 | import OAuth from "oauth"; 10 | import { 11 | getOAuthRequestToken, 12 | getOAuthAccessTokenWith, 13 | } from "../utils/oauthHelpers"; 14 | import { handleSocialSignInData } from "../utils/handleSocialSignInData"; 15 | import { sendErrorResponse } from "../utils/sendErrorResponse"; 16 | import { breakUserFullName } from "../utils/breakUserFullName"; 17 | import { prisma } from "../db"; 18 | 19 | const router = Router(); 20 | 21 | let twitterOauthClientInstance = new OAuth.OAuth( 22 | "https://api.twitter.com/oauth/request_token", 23 | "https://api.twitter.com/oauth/access_token", 24 | process.env.TWITTER_CONSUMER_API_KEY, 25 | process.env.TWITTER_CONSUMER_API_SECRET, 26 | "1.0A", 27 | null, 28 | "HMAC-SHA1" 29 | ); 30 | 31 | const googleOAuth2ClientInstance = new OAuth2Client({ 32 | clientId: process.env.GIS_CLIENT_ID, 33 | clientSecret: process.env.GIS_CLIENT_SECRET, 34 | redirectUri: `${process.env.AUTH_CLIENT_REDIRECT_BASE_URL}/api/auth/google/callback`, 35 | }); 36 | 37 | /** 38 | * Route to start the oauth process of twitter 39 | */ 40 | router.get("/twitter/start", async (req, res) => { 41 | try { 42 | const { oauthRequestToken, oauthRequestTokenSecret } = 43 | await getOAuthRequestToken(twitterOauthClientInstance); 44 | const method = "authenticate"; 45 | const authUrl = `https://api.twitter.com/oauth/${method}?oauth_token=${oauthRequestToken}`; 46 | res.cookie("twitter_tmp_oauth_token_secret", oauthRequestTokenSecret); 47 | 48 | res.redirect(authUrl); 49 | } catch (err) { 50 | sendErrorResponse(res, err); 51 | } 52 | }); 53 | 54 | /** 55 | * Twitter Oauth Callback 56 | */ 57 | router.get("/twitter/callback", async (req, res) => { 58 | try { 59 | const { 60 | oauth_token, 61 | oauth_verifier, 62 | }: { 63 | oauth_token: string; 64 | oauth_verifier: string; 65 | } = req.query as any; 66 | 67 | const oauthRequestTokenSecret = 68 | req.cookies["twitter_tmp_oauth_token_secret"]; 69 | 70 | const { oauthAccessToken, oauthAccessTokenSecret, results } = 71 | await getOAuthAccessTokenWith( 72 | twitterOauthClientInstance, 73 | oauth_token, 74 | oauthRequestTokenSecret, 75 | oauth_verifier 76 | ); 77 | const user_id: string = results.user_id; 78 | 79 | twitterOauthClientInstance.get( 80 | "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true", 81 | oauthAccessToken, 82 | oauthAccessTokenSecret, 83 | (err, results: any) => { 84 | if (err) throw err; 85 | const userObject = JSON.parse(results); 86 | const picture: string = userObject.profile_image_url_https; 87 | const [first_name, last_name] = breakUserFullName(userObject.name); 88 | const email = userObject.email; 89 | // perform magic 90 | handleSocialSignInData(res, { 91 | first_name, 92 | last_name, 93 | email, 94 | picture, 95 | is_twitter_connected: true, 96 | }); 97 | } 98 | ); 99 | } catch (err) { 100 | console.error(err); 101 | res.redirect("/error"); 102 | } 103 | }); 104 | 105 | /** 106 | * Route to start github auth 107 | */ 108 | router.get("/github/start", async (req, res) => { 109 | try { 110 | const params = new URLSearchParams({ 111 | client_id: process.env.GITHUB_CLIENT_ID ?? "", 112 | redirect_uri: `${process.env.AUTH_CLIENT_REDIRECT_BASE_URL}/api/auth/github/callback`, 113 | scope: "user:email read:user", 114 | }); 115 | const authUrl = `https://github.com/login/oauth/authorize?${params}`; 116 | 117 | res.redirect(authUrl); 118 | } catch (err) { 119 | console.error(err); 120 | res.redirect("/error"); 121 | } 122 | }); 123 | 124 | /** 125 | * GitHub Oauth Callback 126 | */ 127 | router.get("/github/callback", async (req, res) => { 128 | try { 129 | const code = req.query.code; 130 | 131 | const response = await axios({ 132 | url: "https://github.com/login/oauth/access_token", 133 | data: { 134 | client_id: process.env.GITHUB_CLIENT_ID, 135 | client_secret: process.env.GITHUB_CLIENT_SECRET, 136 | code, 137 | }, 138 | }); 139 | 140 | const tmp: string = response.data; 141 | const accessToken = tmp 142 | .split("&") 143 | .filter((e) => e.startsWith("access_token=")) 144 | .map((e) => e.split("access_token=")[1])[0]; 145 | 146 | const userDataResponse = await axios({ 147 | url: "https://api.github.com/user/emails", 148 | headers: { 149 | Authorization: `token ${accessToken}`, 150 | Accept: "application/vnd.github+json", 151 | }, 152 | }); 153 | const email = userDataResponse.data.filter( 154 | (e: any) => e.primary && e.verified 155 | )[0].email; 156 | if (!email) throw { message: "No primary verified email" }; 157 | 158 | const userGeneralDataResponse = await axios({ 159 | url: "https://api.github.com/user", 160 | headers: { 161 | Authorization: `token ${accessToken}`, 162 | Accept: "application/vnd.github+json", 163 | }, 164 | }); 165 | 166 | const [first_name, last_name] = breakUserFullName( 167 | userGeneralDataResponse.data.name 168 | ); 169 | const picture = userGeneralDataResponse.data.avatar_url; 170 | 171 | // perform magic 172 | handleSocialSignInData(res, { 173 | first_name, 174 | last_name, 175 | email, 176 | picture, 177 | is_github_connected: true, 178 | }); 179 | } catch (err) { 180 | console.error(err); 181 | res.redirect("/error"); 182 | } 183 | }); 184 | 185 | router.get("/google/start", async (req, res) => { 186 | try { 187 | const authUrl = googleOAuth2ClientInstance.generateAuthUrl({ 188 | scope: [ 189 | "https://www.googleapis.com/auth/userinfo.profile", 190 | "https://www.googleapis.com/auth/userinfo.email", 191 | ], 192 | }); 193 | res.redirect(authUrl); 194 | } catch (err) { 195 | console.error(err); 196 | res.redirect("/error"); 197 | } 198 | }); 199 | /** 200 | * Google Oauth Callback 201 | */ 202 | router.get("/google/callback", async (req, res) => { 203 | try { 204 | const code: string = (req.query as any).code; 205 | let { tokens } = await googleOAuth2ClientInstance.getToken(code); 206 | googleOAuth2ClientInstance.setCredentials(tokens); 207 | 208 | // get data 209 | // also we're damn sure that the token integrity is okay 210 | const data: any = jwt.decode(tokens.id_token); 211 | 212 | const { 213 | given_name: first_name, 214 | family_name: last_name, 215 | email, 216 | picture, 217 | } = data; 218 | 219 | handleSocialSignInData(res, { 220 | first_name, 221 | last_name, 222 | email, 223 | picture, 224 | is_google_connected: true, 225 | }); 226 | } catch (err) { 227 | console.error(err); 228 | res.redirect("/error"); 229 | } 230 | }); 231 | 232 | /** 233 | * Route to get the profile 234 | */ 235 | router.get("/profile/", isAuthenticated, async (req, res) => { 236 | const user = await prisma.user.findUnique({ 237 | where: { 238 | id: req.user.id, 239 | }, 240 | }); 241 | res.send(user); 242 | }); 243 | 244 | /** 245 | * Route to get the currently authenticated user 246 | */ 247 | router.get("/currentUser/", isAuthenticatedWithoutErr, (req, res) => { 248 | res.send({ 249 | is_logged_in: req.user ? true : false, 250 | user: req.user, 251 | }); 252 | }); 253 | 254 | /** 255 | * Route to logout the user 256 | */ 257 | router.get("/logOut/", isAuthenticated, (req, res) => { 258 | // If I'm inside this route handler 259 | // it means that I'm authenticated 260 | 261 | res.cookie("sigmaKeeper", null, { 262 | maxAge: -1, 263 | }); 264 | res.send({ 265 | error: false, 266 | user: "Logged out successfully!", 267 | }); 268 | }); 269 | 270 | export default router; 271 | -------------------------------------------------------------------------------- /apps/backend/src/routes/insights.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../db"; 2 | import { Router } from "express"; 3 | import { sendErrorResponse } from "../utils/sendErrorResponse"; 4 | import { isAuthenticated } from "../utils/authMiddlewares"; 5 | import { 6 | Insights_TimeSpent_Response, 7 | Insights_Workout_Request, 8 | Insights_Workout_Response, 9 | SessionInstanceState_SetData, 10 | } from "@sigmafit/commons"; 11 | import { workoutInsightsPayloadValidator } from "../validators/insights"; 12 | const router = Router(); 13 | 14 | /** 15 | * Workout insights 16 | */ 17 | router.post("/workout", isAuthenticated, async (req, res) => { 18 | try { 19 | const validatedData: Insights_Workout_Request = 20 | workoutInsightsPayloadValidator.validateSync(req.body); 21 | 22 | // check that either the workout is owned by this person or it's public 23 | const workoutInstance = await prisma.workout.findFirst({ 24 | where: { 25 | id: validatedData.workout_id, 26 | OR: [ 27 | { 28 | owner_id: req.user.id, 29 | }, 30 | { 31 | is_public: true, 32 | }, 33 | ], 34 | }, 35 | }); 36 | if (!workoutInstance) throw { message: "Invalid attempt!" }; 37 | type nice_type = { 38 | workout_id: string; 39 | sets_data: SessionInstanceState_SetData[]; 40 | start_timestamp: string; 41 | end_timestamp: string; 42 | block_type: "NORMAL" | "SUPERSET"; 43 | }; 44 | 45 | const data: nice_type[] = await prisma.$queryRaw` 46 | SELECT 47 | workout_schema.workout_id, 48 | workout_instance.sets_data, 49 | session_instance.start_timestamp, 50 | session_instance.end_timestamp, 51 | 'NORMAL' as block_type 52 | FROM workout_schema 53 | INNER JOIN workout_instance ON workout_instance.workout_schema_id = workout_schema.id 54 | INNER JOIN session_instance ON session_instance.id = workout_instance.session_instance_id 55 | WHERE workout_id=${validatedData.workout_id} 56 | UNION 57 | SELECT 58 | superset_workout_schema.workout_id, 59 | superset_workout_instance.sets_data, 60 | session_instance.start_timestamp, 61 | session_instance.end_timestamp, 62 | 'SUPERSET' as block_type 63 | FROM superset_workout_schema 64 | INNER JOIN superset_workout_instance ON superset_workout_instance.superset_workout_schema_id = superset_workout_schema.id 65 | INNER JOIN session_instance ON session_instance.id = superset_workout_instance.session_instance_id 66 | WHERE workout_id=${validatedData.workout_id} 67 | ORDER BY start_timestamp`; 68 | 69 | // const supersetData: nice_type[] = await prisma.$queryRaw` 70 | // `; 71 | 72 | const buildDataPoints = (data: nice_type[]) => { 73 | const dataPoints: Insights_Workout_Response["dataPoints"] = []; 74 | 75 | for (const workoutInstance of data) { 76 | for (const set_data of workoutInstance.sets_data) { 77 | // first instance of the set is normal 78 | // all others are dropset 79 | 80 | set_data.values.forEach((f, indx) => { 81 | dataPoints.push({ 82 | date: new Date(workoutInstance.start_timestamp).toDateString(), 83 | setValue: set_data.values[0], 84 | type: indx === 0 ? workoutInstance.block_type : "DROPSET", 85 | }); 86 | }); 87 | } 88 | } 89 | return dataPoints; 90 | }; 91 | 92 | const resp: Insights_Workout_Response = { 93 | workout_type: workoutInstance.category, 94 | dataPoints: buildDataPoints(data), 95 | }; 96 | res.send(resp); 97 | } catch (err) { 98 | sendErrorResponse(res, err); 99 | } 100 | }); 101 | 102 | router.post("/timeSpent", isAuthenticated, async (req, res) => { 103 | try { 104 | // look for all session schema 105 | // Note: we aren't merging it! 106 | 107 | const sessionInstances = await prisma.session_instance.findMany({ 108 | where: { 109 | session_schema: { 110 | owner_id: req.user.id, 111 | }, 112 | NOT: { 113 | end_timestamp: null, 114 | }, 115 | }, 116 | include: { 117 | session_schema: { 118 | select: { 119 | name: true, 120 | }, 121 | }, 122 | }, 123 | orderBy: { 124 | start_timestamp: "asc", 125 | }, 126 | }); 127 | 128 | const resp: Insights_TimeSpent_Response = { 129 | dataPoints: sessionInstances 130 | .map((e) => ({ 131 | startTime: e.start_timestamp, 132 | session_name: e.session_schema.name, 133 | duration: 134 | Math.round( 135 | ((e.end_timestamp.getTime() - e.start_timestamp.getTime()) / 136 | 1000 / 137 | 60) * 138 | 100 139 | ) / 100, // minutes 140 | })) 141 | .slice(0, 5), 142 | }; 143 | 144 | res.send(resp); 145 | } catch (err) { 146 | sendErrorResponse(res, err); 147 | } 148 | }); 149 | export default router; 150 | -------------------------------------------------------------------------------- /apps/backend/src/routes/workout.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../db"; 2 | import { Router } from "express"; 3 | import { sendErrorResponse } from "../utils/sendErrorResponse"; 4 | import { isAuthenticated } from "../utils/authMiddlewares"; 5 | 6 | import { 7 | WorkoutFormOptionsResponse, 8 | WorkoutListResponse, 9 | Workout_AddOrModify_Response, 10 | Workout_Delete_Response, 11 | } from "@sigmafit/commons"; 12 | import { 13 | body_part, 14 | intensity_levels, 15 | workout, 16 | workout_type, 17 | } from "@prisma/client"; 18 | import { 19 | addOrModifyWorkoutPayloadValidator, 20 | deleteWorkoutPayloadValidator, 21 | } from "../validators/workout"; 22 | import { v4 } from "uuid"; 23 | 24 | const router = Router(); 25 | 26 | /** 27 | * Route to list all workouts 28 | */ 29 | router.get("/list/", isAuthenticated, async (req, res) => { 30 | try { 31 | const workouts = await prisma.workout.findMany({ 32 | where: { 33 | OR: [ 34 | { 35 | owner_id: req.user.id, 36 | }, 37 | { 38 | is_public: true, 39 | }, 40 | ], 41 | }, 42 | }); 43 | 44 | const publicWorkouts: workout[] = []; 45 | const myWorkouts: workout[] = []; 46 | workouts.forEach((e) => { 47 | // we're giving priority to is_public; if the workout is public, then we shall not allow any edits! 48 | if (e.is_public) publicWorkouts.push(e); 49 | else if (e.owner_id === req.user.id) myWorkouts.push(e); 50 | }); 51 | 52 | const response: WorkoutListResponse = { 53 | publicWorkouts, 54 | myWorkouts, 55 | }; 56 | res.send(response); 57 | } catch (err) { 58 | return sendErrorResponse(res, err); 59 | } 60 | }); 61 | 62 | /** 63 | * Route to add a workout 64 | * The workout will be private to the user 65 | */ 66 | router.post("/addOrModify/", isAuthenticated, async (req, res) => { 67 | try { 68 | const validatedData = await addOrModifyWorkoutPayloadValidator.validate( 69 | req.body 70 | ); 71 | // We're adding the url here, but it has no security threat, as the workout is inaccessible to others until we make it public! 72 | 73 | // Create Mode 74 | let response: Workout_AddOrModify_Response; 75 | 76 | if (!validatedData.id) { 77 | // Note: we're not using upsert since we want to check the owner too! (somehow prisma don't allow that) 78 | const workout = await prisma.workout.create({ 79 | data: { 80 | category: validatedData.category, 81 | id: v4(), 82 | name: validatedData.name, 83 | intensity: validatedData.intensity, 84 | owner_id: req.user.id, 85 | target_body_part: validatedData.target_body_part, 86 | notes: validatedData.notes, 87 | workout_image_url: validatedData.workout_image_url, 88 | }, 89 | }); 90 | response = { 91 | workout, 92 | mode: "CREATE", 93 | }; 94 | } else { 95 | const workout = await prisma.workout.update({ 96 | where: { 97 | id: validatedData.id, 98 | }, 99 | data: { 100 | category: validatedData.category, 101 | name: validatedData.name, 102 | intensity: validatedData.intensity, 103 | owner_id: req.user.id, 104 | target_body_part: validatedData.target_body_part, 105 | notes: validatedData.notes, 106 | workout_image_url: validatedData.workout_image_url, 107 | }, 108 | }); 109 | response = { 110 | workout, 111 | mode: "EDIT", 112 | }; 113 | } 114 | 115 | res.send(response); 116 | } catch (err) { 117 | return sendErrorResponse(res, err); 118 | } 119 | }); 120 | 121 | /** 122 | * Route to modify a workout added by the user 123 | * 124 | */ 125 | router.post("/modify/", isAuthenticated, async (req, res) => { 126 | try { 127 | // REASON: Why do we want the person to be able to modify? These are very basic things. Keep things simple 128 | throw { status: 400, message: "Not allowed for now!" }; 129 | 130 | // const data = req.body; 131 | // if (data.category) { 132 | // // category cannot be changed 133 | // throw { 134 | // status: 400, 135 | // message: 136 | // "Category cannot be changed! Please consider adding new workout", 137 | // }; 138 | // } 139 | // await modifyWorkoutPayloadValidator.validate(data); 140 | // // TODO: Think of a way to dynamically add a favicon; Maybe we just consider the body type to have one? 141 | 142 | // const result = await prisma.workout.updateMany({ 143 | // where: { 144 | // owner_id: req.user.id, 145 | // id: data.id, 146 | // }, 147 | // data: { 148 | // name: data.name, 149 | // // Note: No category here 150 | // intensity: data.intensity, 151 | // target_body_part: data.target_body_part, 152 | // }, 153 | // }); 154 | 155 | // if (result.count > 1) 156 | // throw { 157 | // status: 400, 158 | // message: "Logic Error. The developers needs to be fired!", 159 | // }; 160 | // else if (result.count == 0) 161 | // throw { status: 400, message: "Invalid workout id or permission" }; 162 | 163 | // const workout = await prisma.workout.findFirst({ 164 | // where: { 165 | // id: data.id, 166 | // owner_id: req.user.id, 167 | // }, 168 | // }); 169 | 170 | // res.send({ 171 | // workout, 172 | // }); 173 | } catch (err) { 174 | return sendErrorResponse(res, err); 175 | } 176 | }); 177 | 178 | /** 179 | * Route to delete a workout is removed. 180 | * 181 | * Any workout added cannot be removed. It ensures that any shared schema workouts are always safe! 182 | */ 183 | router.post("/delete", isAuthenticated, async (req, res) => { 184 | try { 185 | const validatedData = deleteWorkoutPayloadValidator.validateSync(req.body); 186 | // there is no 187 | const workoutInstance = await prisma.workout.findFirst({ 188 | where: { 189 | id: validatedData.id, 190 | owner_id: req.user.id, 191 | is_public: false, 192 | }, 193 | }); 194 | 195 | if (!workoutInstance) 196 | throw { message: "Invalid attempt to delete workout!" }; 197 | 198 | // check that nobody is using it 199 | let workoutDoc = await prisma.workout_schema.findFirst({ 200 | where: { 201 | workout_id: validatedData.id, 202 | }, 203 | }); 204 | 205 | if (workoutDoc) 206 | throw { 207 | message: `This workout is being used by one of the workout routine!`, 208 | }; 209 | 210 | let supersetWorkoutDoc = await prisma.superset_workout_schema.findFirst({ 211 | where: { 212 | workout_id: validatedData.id, 213 | }, 214 | }); 215 | 216 | if (supersetWorkoutDoc) 217 | throw { 218 | message: `This workout is being used by one of the workout routine!`, 219 | }; 220 | 221 | // delete the workout 222 | await prisma.workout.delete({ 223 | where: { 224 | id: validatedData.id, 225 | }, 226 | }); 227 | 228 | const resp: Workout_Delete_Response = { 229 | message: `workout ${workoutInstance.name} deleted successfully!`, 230 | deleted_workout_id: workoutInstance.id, 231 | }; 232 | res.send(resp); 233 | } catch (err) { 234 | return sendErrorResponse(res, err); 235 | } 236 | }); 237 | 238 | /** 239 | * Route to send all the form options to add a new form 240 | */ 241 | router.get("/formOptions/", isAuthenticated, (req, res) => { 242 | // type a= 243 | const response: WorkoutFormOptionsResponse = { 244 | category: Object.keys(workout_type) as any, 245 | target_body_part: Object.keys(body_part) as any, 246 | intensity: Object.keys(intensity_levels) as any, 247 | }; 248 | 249 | res.send(response); 250 | }); 251 | 252 | export default router; 253 | -------------------------------------------------------------------------------- /apps/backend/src/utils/authMiddlewares.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import { sendErrorResponse } from "./sendErrorResponse"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | /** 6 | * Middleware function to check if the user is authenticated or not 7 | * 8 | * We'll use the middleware only if we want the authenticated users data; 9 | */ 10 | export const isAuthenticated: RequestHandler = (req, res, next) => { 11 | try { 12 | const token = req.cookies["sigmaKeeper"]; 13 | const res: any = jwt.verify(token, process.env.JSON_WEB_TOKEN_SECRET); 14 | req.user = res; 15 | next(); 16 | } catch (err) { 17 | sendErrorResponse(res, { 18 | status: 401, 19 | message: err.message, 20 | }); 21 | } 22 | }; 23 | 24 | /** 25 | * Middleware function to check if the user is authenticated or not. (but doesn't throw error) 26 | * 27 | * We'll use the middleware only if we want the authenticated users data; 28 | */ 29 | export const isAuthenticatedWithoutErr: RequestHandler = (req, res, next) => { 30 | try { 31 | const token = req.cookies["sigmaKeeper"]; 32 | const res: any = jwt.verify(token, process.env.JSON_WEB_TOKEN_SECRET); 33 | req.user = res; 34 | } catch (err) {} 35 | next(); 36 | }; 37 | 38 | export const isNotAuthenticated: RequestHandler = (req, res, next) => { 39 | // TODO 40 | next(); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/backend/src/utils/breakUserFullName.ts: -------------------------------------------------------------------------------- 1 | export const breakUserFullName = (fullName: string) => { 2 | const chunks = fullName 3 | .split(" ") 4 | .map((e) => e.trim()) 5 | .filter((e) => e.length); 6 | const first_name = chunks.splice(0, 1).join(" "); 7 | const last_name = chunks.length ? chunks.join(" ") : "_"; 8 | return [first_name, last_name]; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/backend/src/utils/handleSocialSignInData.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../db"; 2 | import { Response } from "express"; 3 | import { setAuthTokenAsCookie } from "./setAuthTokenAsCookie"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | 6 | export const handleSocialSignInData = async ( 7 | res: Response, 8 | userData: { 9 | first_name: string; 10 | last_name: string; 11 | email: string; 12 | picture: string; 13 | is_github_connected?: boolean; 14 | is_google_connected?: boolean; 15 | is_twitter_connected?: boolean; 16 | } 17 | ) => { 18 | const { 19 | email, 20 | first_name, 21 | last_name, 22 | picture, 23 | is_github_connected, 24 | is_google_connected, 25 | is_twitter_connected, 26 | } = userData; 27 | 28 | const tmpObj: Record = { 29 | is_google_connected, 30 | is_github_connected, 31 | is_twitter_connected, 32 | }; 33 | 34 | const socialConnectionStatus: Record = {}; 35 | Object.keys(tmpObj) 36 | .filter((e) => tmpObj[e]) 37 | .forEach((e) => (socialConnectionStatus[e] = true)); 38 | 39 | const user = await prisma.user.findUnique({ 40 | where: { 41 | email, 42 | }, 43 | }); 44 | 45 | if (user) { 46 | // update async 47 | const newUser = await prisma.user.update({ 48 | where: { 49 | email: user.email, 50 | }, 51 | data: { 52 | ...socialConnectionStatus, 53 | last_token_generated_at: new Date(), 54 | }, 55 | }); 56 | 57 | // login user 58 | setAuthTokenAsCookie(res, newUser); 59 | } else { 60 | // register user 61 | const uuid = uuidv4(); 62 | 63 | const newUser = await prisma.user.create({ 64 | data: { 65 | ...socialConnectionStatus, 66 | id: uuid, 67 | first_name, 68 | last_name, 69 | email, 70 | picture, 71 | }, 72 | }); 73 | setAuthTokenAsCookie(res, newUser); 74 | } 75 | res.redirect("/dash"); 76 | }; 77 | -------------------------------------------------------------------------------- /apps/backend/src/utils/oauthHelpers.ts: -------------------------------------------------------------------------------- 1 | import { OAuth } from "oauth"; 2 | 3 | // CREDITS: https://cri.dev/posts/2020-03-05-Twitter-OAuth-Login-by-example-with-Node.js/ 4 | 5 | export async function getOAuthRequestToken(oauthConsumer: OAuth): Promise<{ 6 | oauthRequestToken: string; 7 | oauthRequestTokenSecret: string; 8 | results: any; 9 | }> { 10 | return new Promise((resolve, reject) => { 11 | oauthConsumer.getOAuthRequestToken(function ( 12 | error, 13 | oauthRequestToken, 14 | oauthRequestTokenSecret, 15 | results 16 | ) { 17 | return error 18 | ? reject(new Error("Error getting OAuth request token")) 19 | : resolve({ oauthRequestToken, oauthRequestTokenSecret, results }); 20 | }); 21 | }); 22 | } 23 | 24 | export async function getOAuthAccessTokenWith( 25 | oauthConsumer: OAuth, 26 | oauthRequestToken: string, 27 | oauthRequestTokenSecret: string, 28 | oauthVerifier: string 29 | ): Promise<{ 30 | oauthAccessToken: string; 31 | oauthAccessTokenSecret: string; 32 | results: any; 33 | }> { 34 | return new Promise((resolve, reject) => { 35 | oauthConsumer.getOAuthAccessToken( 36 | oauthRequestToken, 37 | oauthRequestTokenSecret, 38 | oauthVerifier, 39 | function (error, oauthAccessToken, oauthAccessTokenSecret, results) { 40 | return error 41 | ? reject(new Error("Error getting OAuth access token")) 42 | : resolve({ oauthAccessToken, oauthAccessTokenSecret, results }); 43 | } 44 | ); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /apps/backend/src/utils/sendErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { Response } from "express"; 3 | 4 | export type ErrorObjectSchema = { status: number; message: string }; 5 | 6 | /** 7 | * Utility function to create a error response 8 | * 9 | * Must be used inside the route handler; 10 | * 11 | * For generic errors which don't contain the status; We send 400 12 | */ 13 | export const sendErrorResponse = ( 14 | res: Response, 15 | err: ErrorObjectSchema | Error | Prisma.PrismaClientKnownRequestError 16 | ) => { 17 | if (err instanceof Prisma.PrismaClientKnownRequestError) { 18 | // TODO: Make error messages better 19 | } 20 | 21 | return res.status((err as any).status ?? 400).send({ 22 | error: true, 23 | message: err.message, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/backend/src/utils/setAuthTokenAsCookie.ts: -------------------------------------------------------------------------------- 1 | import { user } from "@prisma/client"; 2 | import { Response } from "express"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | export type jwtUserPayloadType = { 6 | id: string; 7 | email: string; 8 | fName: string; 9 | lName: string; 10 | }; 11 | /** 12 | * Utility function to set auth token 13 | */ 14 | export const setAuthTokenAsCookie = (res: Response, user: user) => { 15 | // create auth token 16 | const jwtPayload: jwtUserPayloadType = { 17 | id: user.id, 18 | email: user.email, 19 | fName: user.first_name, 20 | lName: user.last_name, 21 | }; 22 | const token = jwt.sign(jwtPayload, process.env.JSON_WEB_TOKEN_SECRET); 23 | return res.cookie("sigmaKeeper", token, { 24 | secure: process.env.USE_SECURE_COOKIE === "1" ? true : false, 25 | sameSite: "lax", 26 | httpOnly: true, 27 | maxAge: 7 * 24 * 3600000, // 7 days 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /apps/backend/src/validators/auth.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | /** 4 | * validator for signIn payload 5 | */ 6 | export const signInPayloadValidator = yup.object().shape({ 7 | email: yup.string().email().required(), 8 | password: yup.string().min(8).required(), 9 | }); 10 | 11 | /** 12 | * validator for signUp payload 13 | */ 14 | export const signUpPayloadValidator = yup.object().shape({ 15 | first_name: yup.string().required(), 16 | last_name: yup.string().required(), 17 | email: yup.string().email().required(), 18 | password: yup.string().min(8).required(), 19 | }); 20 | -------------------------------------------------------------------------------- /apps/backend/src/validators/insights.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const workoutInsightsPayloadValidator = yup.object().shape({ 4 | workout_id: yup.string().uuid().required(), 5 | timeFrame: yup.mixed().oneOf(["max", "last_month"]).required(), 6 | }); 7 | -------------------------------------------------------------------------------- /apps/backend/src/validators/isValidUUID.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const isValidUUID = (id: string) => { 4 | return yup.string().uuid().required().isValid(id); 5 | }; 6 | -------------------------------------------------------------------------------- /apps/backend/src/validators/sessionInstance.ts: -------------------------------------------------------------------------------- 1 | import { workout_type } from "@prisma/client"; 2 | import { SessionInstanceAddOrModifyBlockRequest } from "@sigmafit/commons"; 3 | import * as yup from "yup"; 4 | 5 | // TODO: Further ensure that 6 | export const isInstanceDataValid = async ( 7 | workoutType: string, 8 | data: SessionInstanceAddOrModifyBlockRequest["sets_data"] 9 | ) => { 10 | // type 11 | 12 | if (workoutType !== workout_type.WEIGHT_AND_REPS) { 13 | data.forEach((values) => { 14 | if (values.values.length > 1) 15 | throw { 16 | message: `Drop sets are only available for ${workout_type.WEIGHT_AND_REPS} type`, 17 | }; 18 | }); 19 | } 20 | 21 | let schema: any; 22 | if ( 23 | workoutType === workout_type.REPS || 24 | workoutType === workout_type.WEIGHT_AND_REPS 25 | ) { 26 | schema = yup.object().shape({ 27 | reps: yup.number().min(0).required(), 28 | }); 29 | 30 | if (workoutType === workout_type.WEIGHT_AND_REPS) { 31 | schema = schema.shape({ 32 | weight: yup.number().min(0).required(), 33 | }); 34 | } 35 | } else { 36 | schema = yup.object().shape({ 37 | duration: yup.number().min(0).required(), 38 | }); 39 | 40 | if (workoutType === workout_type.DISTANCE_AND_DURATION) { 41 | schema = schema.shape({ 42 | distance: yup.number().min(0).required(), 43 | }); 44 | } 45 | } 46 | 47 | return yup 48 | .array() 49 | .required() 50 | .min(1) 51 | .of( 52 | yup.object().shape({ 53 | values: yup.array().required().of(schema).min(1), 54 | }) 55 | ) 56 | .validate(data); 57 | }; 58 | 59 | export const isValidSessionInstanceBlock = yup.object().shape({ 60 | id: yup.string().uuid().required(), 61 | session_instance_id: yup.string().uuid().required(), 62 | block_type: yup 63 | .mixed() 64 | .required() 65 | .oneOf(["SUPERSET_WORKOUT", "CLASSIC_WORKOUT"]), 66 | sets_data: yup.array().required().min(1), 67 | }); 68 | 69 | /** 70 | 71 | Data: 72 | 73 | data: [ 74 | { 75 | "values": [ 76 | { 77 | "weight": xx, 78 | "reps": xx, 79 | }, 80 | // All {values} after first instance are DROP SETS;; 81 | { 82 | "weight": xx, 83 | "reps": xx, 84 | }, 85 | { 86 | "weight": xx, 87 | "reps": xx, 88 | } 89 | ] 90 | } 91 | ] 92 | */ 93 | -------------------------------------------------------------------------------- /apps/backend/src/validators/sessionSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | /** 4 | * validator for add a new sessionSchema 5 | */ 6 | 7 | const workoutSchemaPayloadValidator = yup.object().shape({ 8 | default_target: yup.array().required().min(1).of(yup.number().required()), 9 | workout_id: yup.string().uuid().required(), 10 | order: yup.number().required(), 11 | }); 12 | const addSupersetSchemaPayloadValidator = yup.object().shape({ 13 | name: yup.string().required(), 14 | order: yup.number().required(), 15 | superset_workout_schema: yup 16 | .array() 17 | .required() 18 | .of(workoutSchemaPayloadValidator) 19 | .min(1), 20 | }); 21 | 22 | export const addSessionSchemaPayloadValidator = yup.object().shape({ 23 | session_name: yup.string().required(), 24 | schema_blocks: yup 25 | .array() 26 | .required() 27 | .min(1) 28 | .of( 29 | yup.lazy((item) => { 30 | if ("workout_id" in item) return workoutSchemaPayloadValidator; 31 | else if ("superset_workout_schema" in item) 32 | return addSupersetSchemaPayloadValidator; 33 | else 34 | throw { 35 | message: 36 | "Need an instance of workout or superset schema with type field as workout_schema_block | superset_schema_block", 37 | }; 38 | }) as any 39 | ), 40 | }); 41 | 42 | export const submitSessionSchemaForReviewPayloadValidator = yup.object().shape({ 43 | schema_id: yup.string().uuid().required(), 44 | }); 45 | 46 | export const voteSessionSchemaPayloadValidator = yup.object().shape({ 47 | schema_id: yup.string().uuid().required(), 48 | state: yup.bool().required(), 49 | }); 50 | 51 | export const topSessionSchemaPayloadValidator = yup.object().shape({ 52 | cursor_id: yup.string().nullable().uuid(), 53 | }); 54 | -------------------------------------------------------------------------------- /apps/backend/src/validators/workout.ts: -------------------------------------------------------------------------------- 1 | import { body_part, intensity_levels, workout_type } from "@prisma/client"; 2 | import * as yup from "yup"; 3 | 4 | /** 5 | * validator for add a new (private) workout 6 | */ 7 | export const addOrModifyWorkoutPayloadValidator = yup.object().shape({ 8 | id: yup.string().uuid(), 9 | name: yup.string().required(), 10 | category: yup.mixed().required().oneOf(Object.keys(workout_type)), 11 | target_body_part: yup.mixed().oneOf(Object.keys(body_part)), 12 | intensity: yup.mixed().oneOf(Object.keys(intensity_levels)), 13 | workout_image_url: yup.string().url(), 14 | notes: yup.string(), 15 | }); 16 | 17 | export const deleteWorkoutPayloadValidator = yup.object().shape({ 18 | id: yup.string().uuid().required(), 19 | }); 20 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es2017", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "baseUrl": "." 11 | // "paths": { 12 | // "@coreTypes/*": [ 13 | // "../types/*" 14 | // ] 15 | // }, 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | // "../types/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /apps/commons/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | parser: "@typescript-eslint/parser", 8 | parserOptions: { 9 | ecmaVersion: "latest", 10 | sourceType: "module", 11 | }, 12 | plugins: ["@typescript-eslint"], 13 | rules: {}, 14 | }; 15 | -------------------------------------------------------------------------------- /apps/commons/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | dist/ 5 | -------------------------------------------------------------------------------- /apps/commons/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sigmafit/commons", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "watch": "tsc -w", 9 | "build": "tsc -b", 10 | "lint": "eslint src/** && tsc --noEmit", 11 | "format": "prettier --write . --ignore-path .gitignore" 12 | }, 13 | "devDependencies": { 14 | "@typescript-eslint/eslint-plugin": "^5.30.5", 15 | "@typescript-eslint/parser": "^5.30.5", 16 | "eslint": "^8.19.0", 17 | "prettier": "^2.7.1", 18 | "typescript": "^4.7.4" 19 | }, 20 | "dependencies": {} 21 | } 22 | -------------------------------------------------------------------------------- /apps/commons/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./responseTypes/workout"; 2 | export * from "./responseTypes/sessionInstance"; 3 | export * from "./responseTypes/sessionSchema"; 4 | export * from "./responseTypes/auth"; 5 | export * from "./responseTypes/insights"; 6 | 7 | export * from "./requestTypes/sessionSchema"; 8 | export * from "./requestTypes/sessionInstance"; 9 | export * from "./requestTypes/insights"; 10 | export * from "./requestTypes/workout"; 11 | 12 | import * as PrismaGenTypes from "./prismaGenTypes"; 13 | 14 | export { PrismaGenTypes }; 15 | -------------------------------------------------------------------------------- /apps/commons/src/prismaGenTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Client 3 | **/ 4 | 5 | /** 6 | * Model user 7 | * 8 | */ 9 | export type user = { 10 | id: string; 11 | first_name: string; 12 | last_name: string; 13 | picture: string; 14 | email: string; 15 | is_google_connected: boolean; 16 | is_github_connected: boolean; 17 | is_twitter_connected: boolean; 18 | created_time: Date; 19 | last_token_generated_at: Date; 20 | }; 21 | 22 | /** 23 | * Model workout 24 | * 25 | */ 26 | export type workout = { 27 | id: string; 28 | name: string; 29 | category: workout_type; 30 | target_body_part: body_part | null; 31 | workout_image_url: string; 32 | intensity: intensity_levels | null; 33 | owner_id: string; 34 | is_public: boolean; 35 | notes: string; 36 | }; 37 | 38 | /** 39 | * Model session_schema 40 | * 41 | */ 42 | export type session_schema = { 43 | id: string; 44 | name: string; 45 | owner_id: string; 46 | state: schema_state; 47 | votes_count: number; 48 | number_of_workouts: number; 49 | number_of_superset_workouts: number; 50 | number_of_workouts_in_superset: number; 51 | }; 52 | 53 | /** 54 | * Model session_schema_vote_by_user 55 | * 56 | */ 57 | export type session_schema_vote_by_user = { 58 | user_id: string; 59 | session_schema_id: string; 60 | voted_at: Date; 61 | }; 62 | 63 | /** 64 | * Model workout_schema 65 | * 66 | */ 67 | export type workout_schema = { 68 | id: string; 69 | session_schema_id: string; 70 | workout_id: string; 71 | default_target: any; 72 | order: number; 73 | }; 74 | 75 | /** 76 | * Model superset_schema 77 | * 78 | */ 79 | export type superset_schema = { 80 | id: string; 81 | name: string; 82 | session_schema_id: string; 83 | }; 84 | 85 | /** 86 | * Model superset_workout_schema 87 | * 88 | */ 89 | export type superset_workout_schema = { 90 | id: string; 91 | superset_schema_id: string; 92 | workout_id: string; 93 | default_target: any; 94 | order: number; 95 | }; 96 | 97 | /** 98 | * Model session_instance 99 | * 100 | */ 101 | export type session_instance = { 102 | id: string; 103 | session_schema_id: string; 104 | start_timestamp: Date; 105 | end_timestamp: Date | null; 106 | }; 107 | 108 | /** 109 | * Model workout_instance 110 | * 111 | */ 112 | export type workout_instance = { 113 | workout_schema_id: string; 114 | session_instance_id: string; 115 | sets_data: any; 116 | }; 117 | 118 | /** 119 | * Model superset_workout_instance 120 | * 121 | */ 122 | export type superset_workout_instance = { 123 | superset_workout_schema_id: string; 124 | session_instance_id: string; 125 | sets_data: any; 126 | }; 127 | 128 | /** 129 | * Enums 130 | */ 131 | 132 | // Based on 133 | // https://github.com/microsoft/TypeScript/issues/3192#issuecomment-261720275 134 | 135 | export let workout_type: { 136 | WEIGHT_AND_REPS: "WEIGHT_AND_REPS"; 137 | REPS: "REPS"; 138 | DISTANCE_AND_DURATION: "DISTANCE_AND_DURATION"; 139 | DURATION: "DURATION"; 140 | }; 141 | 142 | export type workout_type = typeof workout_type[keyof typeof workout_type]; 143 | 144 | export let body_part: { 145 | ABS: "ABS"; 146 | BICEPS: "BICEPS"; 147 | TRICEPS: "TRICEPS"; 148 | BACK: "BACK"; 149 | CARDIO: "CARDIO"; 150 | CHEST: "CHEST"; 151 | CORE: "CORE"; 152 | FOREARMS: "FOREARMS"; 153 | FULL_BODY: "FULL_BODY"; 154 | LEGS: "LEGS"; 155 | CALFS: "CALFS"; 156 | SHOULDERS: "SHOULDERS"; 157 | TRAPS: "TRAPS"; 158 | OTHERS: "OTHERS"; 159 | }; 160 | 161 | export type body_part = typeof body_part[keyof typeof body_part]; 162 | 163 | export let intensity_levels: { 164 | VERY_HARD: "VERY_HARD"; 165 | HARD: "HARD"; 166 | MEDIUM: "MEDIUM"; 167 | EASY: "EASY"; 168 | WARMUP: "WARMUP"; 169 | }; 170 | 171 | export type intensity_levels = 172 | typeof intensity_levels[keyof typeof intensity_levels]; 173 | 174 | export let schema_state: { 175 | PRIVATE: "PRIVATE"; 176 | PUBLIC: "PUBLIC"; 177 | REVIEW: "REVIEW"; 178 | }; 179 | 180 | export type schema_state = typeof schema_state[keyof typeof schema_state]; 181 | -------------------------------------------------------------------------------- /apps/commons/src/requestTypes/insights.ts: -------------------------------------------------------------------------------- 1 | export type Insights_Workout_Request = { 2 | workout_id: string; 3 | timeFrame: "max" | "last_month"; 4 | }; 5 | -------------------------------------------------------------------------------- /apps/commons/src/requestTypes/sessionInstance.ts: -------------------------------------------------------------------------------- 1 | import { SessionInstanceState_SetData } from "../responseTypes/sessionInstance"; 2 | 3 | export type SessionInstanceAddOrModifyBlockRequest = { 4 | block_type: "CLASSIC_WORKOUT" | "SUPERSET_WORKOUT"; 5 | id: string; 6 | session_instance_id: string; 7 | sets_data: SessionInstanceState_SetData[]; 8 | }; 9 | -------------------------------------------------------------------------------- /apps/commons/src/requestTypes/sessionSchema.ts: -------------------------------------------------------------------------------- 1 | export type create_session_schema__workout_schema = { 2 | workout_id: string; 3 | default_target: any; 4 | id: string; 5 | order: string | number; 6 | }; 7 | 8 | export type create_session_schema__superset_schema = { 9 | name: string; 10 | superset_workout_schema: create_session_schema__workout_schema[]; 11 | id: string; 12 | order: string | number; 13 | }; 14 | 15 | export type SessionSchemaCreateRequest = { 16 | session_name: string; 17 | schema_blocks: ( 18 | | create_session_schema__superset_schema 19 | | create_session_schema__workout_schema 20 | )[]; 21 | }; 22 | 23 | export type SessionSchemaVoteRequest = { 24 | schema_id: string; 25 | state: boolean; 26 | }; 27 | 28 | export type SessionSchema_SubmitForReview_Request = { 29 | schema_id: string; 30 | }; 31 | 32 | export type SessionSchema_Top_Request = { 33 | cursor_id: string | null; 34 | }; 35 | -------------------------------------------------------------------------------- /apps/commons/src/requestTypes/workout.ts: -------------------------------------------------------------------------------- 1 | import { body_part, intensity_levels, workout_type } from "../prismaGenTypes"; 2 | 3 | export type Workout_AddOrModify_Request = { 4 | name: string; 5 | category: keyof typeof workout_type; 6 | target_body_part?: keyof typeof body_part; 7 | intensity?: keyof typeof intensity_levels; 8 | workout_image_url?: string; 9 | notes: string; 10 | id?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/commons/src/responseTypes/auth.ts: -------------------------------------------------------------------------------- 1 | import { workout } from "../prismaGenTypes"; 2 | -------------------------------------------------------------------------------- /apps/commons/src/responseTypes/insights.ts: -------------------------------------------------------------------------------- 1 | import { workout_type } from "../prismaGenTypes"; 2 | import { SessionInstanceState_SetData } from "./sessionInstance"; 3 | 4 | export type Insights_Workout_Response = { 5 | workout_type: workout_type; 6 | dataPoints: { 7 | date: string; 8 | setValue: SessionInstanceState_SetData["values"][number]; 9 | type: "DROPSET" | "NORMAL" | "SUPERSET"; 10 | }[]; 11 | }; 12 | 13 | export type Insights_TimeSpent_Response = { 14 | dataPoints: { 15 | duration: number; 16 | startTime: Date; // it's date 17 | session_name: string; 18 | }[]; 19 | }; 20 | -------------------------------------------------------------------------------- /apps/commons/src/responseTypes/sessionInstance.ts: -------------------------------------------------------------------------------- 1 | import { 2 | session_instance, 3 | superset_schema, 4 | superset_workout_instance, 5 | workout_instance, 6 | workout_type, 7 | } from "../prismaGenTypes"; 8 | 9 | export type SessionInstanceStartResponse = session_instance; 10 | 11 | export type SessionInstanceState_SetData_WEIGHT_AND_REPS = { 12 | weight: number; 13 | reps: number; 14 | }; 15 | 16 | export type SessionInstanceState_SetData_REPS = { 17 | reps: number; 18 | }; 19 | 20 | export type SessionInstanceState_SetData_DISTANCE_AND_DURATION = { 21 | duration: number; 22 | distance: number; 23 | }; 24 | 25 | export type SessionInstanceState_SetData_DURATION = { 26 | duration: number; 27 | }; 28 | 29 | export type SessionInstanceState_SetData = { 30 | values: Array< 31 | | SessionInstanceState_SetData_WEIGHT_AND_REPS 32 | | SessionInstanceState_SetData_REPS 33 | | SessionInstanceState_SetData_DISTANCE_AND_DURATION 34 | | SessionInstanceState_SetData_DURATION 35 | >; 36 | }; 37 | // TODO: Shall we add just DISTANCE? 38 | 39 | export type SessionInstanceStateResponse = { 40 | superset_schema_details: Record; 41 | session_workouts: { 42 | superset_or_classic_workout_schema_id: string; 43 | workout_id: string; 44 | default_target: (string | number)[]; 45 | order: number; 46 | current_workout_instance_sets_data: SessionInstanceState_SetData[]; 47 | prev_workout_instance_sets_data: SessionInstanceState_SetData[]; 48 | type: "CLASSIC_WORKOUT" | "SUPERSET_WORKOUT"; 49 | workout_category: keyof typeof workout_type; 50 | workout_name: string; 51 | workout_image_url: string; 52 | superset_schema_name: string | null; 53 | }[]; 54 | session_instance_details: { 55 | schema_name: string; 56 | end_timestamp: Date; 57 | start_timestamp: Date; 58 | }; 59 | }; 60 | 61 | export type SessionInstanceAddOrModifyBlockResponse = 62 | | workout_instance 63 | | superset_workout_instance; 64 | 65 | export type SessionInstanceAllActiveResponse = (session_instance & { 66 | session_schema: { 67 | name: string; 68 | owner_id: string; 69 | }; 70 | })[]; 71 | 72 | export type SessionInstanceEndResponse = { 73 | message: string; 74 | }; 75 | -------------------------------------------------------------------------------- /apps/commons/src/responseTypes/sessionSchema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | workout_schema, 3 | superset_schema, 4 | session_schema, 5 | superset_workout_schema, 6 | session_schema_vote_by_user, 7 | schema_state, 8 | } from "../prismaGenTypes"; 9 | 10 | export type SessionSchemaDetailsResponse = { 11 | schema_blocks: ( 12 | | (workout_schema & { 13 | workout: { 14 | name: string; 15 | }; 16 | }) 17 | | (superset_schema & { 18 | superset_workout_schema: (superset_workout_schema & { 19 | workout: { 20 | name: string; 21 | }; 22 | })[]; 23 | }) 24 | )[]; 25 | id: string; 26 | session_name: string; 27 | state: schema_state; 28 | votes_count: number; 29 | number_of_workouts: number; 30 | number_of_superset_workouts: number; 31 | number_of_workouts_in_superset: number; 32 | }; 33 | 34 | export type SessionSchemaCreateResponse = { 35 | session_schema: session_schema; 36 | blocks: ( 37 | | workout_schema 38 | | (superset_schema & { 39 | superset_workout_schema: superset_workout_schema[]; 40 | }) 41 | )[]; 42 | }; 43 | 44 | export type SessionSchemaDeleteResponse = { 45 | message: string; 46 | }; 47 | 48 | export type SessionSchemaAllResponse = { 49 | id: string; 50 | name: string; 51 | last_attempted_at: Date; 52 | end_timestamp: Date | null; 53 | }[]; 54 | 55 | export type SessionSchema_Top_Response = { 56 | next_cursor: string | null; 57 | results: { 58 | id: string; 59 | name: string; 60 | votes_count: number; 61 | session_schema_vote_by_user: session_schema_vote_by_user[]; 62 | number_of_workouts: number; 63 | number_of_superset_workouts: number; 64 | number_of_workouts_in_superset: number; 65 | owner: { 66 | first_name: string; 67 | }; 68 | }[]; 69 | }; 70 | 71 | export type SessionSchemaVoteResponse = session_schema_vote_by_user | null; 72 | 73 | export type SessionSchema_SubmitForReview_Response = { 74 | message: string; 75 | }; 76 | -------------------------------------------------------------------------------- /apps/commons/src/responseTypes/workout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | body_part, 3 | intensity_levels, 4 | workout, 5 | workout_type, 6 | } from "../prismaGenTypes"; 7 | 8 | export type WorkoutListResponse = { 9 | publicWorkouts: workout[]; 10 | myWorkouts: workout[]; 11 | }; 12 | 13 | export type Workout_AddOrModify_Response = { 14 | workout: workout; 15 | mode: "CREATE" | "EDIT"; 16 | }; 17 | 18 | export type WorkoutDeleteResponse = { 19 | message: string; 20 | }; 21 | 22 | export type WorkoutFormOptionsResponse = { 23 | category: (keyof typeof workout_type)[]; 24 | target_body_part: (keyof typeof body_part)[]; 25 | intensity: (keyof typeof intensity_levels)[]; 26 | }; 27 | 28 | export type Workout_Delete_Response = { 29 | message: string; 30 | deleted_workout_id: string; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/commons/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es2017", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "declaration": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules/**"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "react/no-unescaped-entities": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | 38 | /public/sw*.js* 39 | /public/workbox*.js* 40 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /apps/frontend/api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { 4 | Insights_Workout_Request, 5 | SessionSchemaVoteRequest, 6 | SessionSchema_SubmitForReview_Request, 7 | SessionSchema_Top_Request, 8 | WorkoutListResponse, 9 | } from "@sigmafit/commons"; 10 | import { user } from "@sigmafit/commons/dist/prismaGenTypes"; 11 | 12 | const apiPrefixSlug = `/api`; 13 | 14 | export type ErrorResponse = { 15 | error: true; 16 | message: string; 17 | status: number; 18 | }; 19 | 20 | const makePostRequest = async ( 21 | url: string, 22 | method: "GET" | "POST", 23 | data?: any 24 | ): Promise => { 25 | try { 26 | const response = await axios(url, { 27 | data, 28 | method, 29 | withCredentials: true, 30 | }); 31 | return response.data; 32 | } catch (err: any) { 33 | throw { 34 | error: true, 35 | message: err.response.data.message ?? err.message, 36 | status: err.response.status, 37 | }; 38 | } 39 | }; 40 | 41 | // WORKOUT 42 | export const getAllWorkouts = async () => { 43 | return makePostRequest( 44 | `${apiPrefixSlug}/workout/list`, 45 | "GET" 46 | ); 47 | }; 48 | 49 | export const addNewOrModifyWorkoutMutation = async (workoutData: any) => { 50 | const newObj: any = {}; 51 | Object.keys(workoutData).forEach((e) => { 52 | if (workoutData[e]) newObj[e] = workoutData[e]; 53 | }); 54 | return makePostRequest( 55 | `${apiPrefixSlug}/workout/addOrModify`, 56 | "POST", 57 | newObj 58 | ); 59 | }; 60 | 61 | export const getNewWorkoutAddFormOptions = async () => { 62 | return makePostRequest(`${apiPrefixSlug}/workout/formOptions`, "GET"); 63 | }; 64 | 65 | export const deleteWorkout = async (workout_id: string) => { 66 | return makePostRequest(`${apiPrefixSlug}/workout/delete`, "POST", { 67 | id: workout_id, 68 | }); 69 | }; 70 | 71 | /** 72 | * Get all active sessions for the user 73 | */ 74 | export const getAllActiveSessions = async () => { 75 | return makePostRequest( 76 | `${apiPrefixSlug}/sessionInstance/allActive`, 77 | "GET" 78 | ); 79 | }; 80 | 81 | /** 82 | * Get all schema owned by the user 83 | */ 84 | export const getAllSessionSchemaOwnedByUser = async () => { 85 | return makePostRequest(`${apiPrefixSlug}/sessionSchema/all`, "GET"); 86 | }; 87 | 88 | /** 89 | * Start a new session from schema Id 90 | */ 91 | export const startANewSessionFromSchemaId = async (sessionSchemaId: string) => { 92 | return makePostRequest( 93 | `${apiPrefixSlug}/sessionInstance/start`, 94 | "POST", 95 | { 96 | sessionSchemaId, 97 | } 98 | ); 99 | }; 100 | 101 | export const addNewSessionSchema = async (sessionSchema: any) => { 102 | return makePostRequest( 103 | `${apiPrefixSlug}/sessionSchema/create`, 104 | "POST", 105 | sessionSchema 106 | ); 107 | }; 108 | 109 | export const getSessionSchemaDetails = async (schemaId: string) => { 110 | return makePostRequest( 111 | `${apiPrefixSlug}/sessionSchema/details/${schemaId}`, 112 | "GET" 113 | ); 114 | }; 115 | 116 | export const getSessionInstanceDetails = async (schemaInstanceId: string) => { 117 | return makePostRequest( 118 | `${apiPrefixSlug}/sessionInstance/state/${schemaInstanceId}`, 119 | "GET" 120 | ); 121 | }; 122 | 123 | export const getCurrentUser = async () => { 124 | return makePostRequest(`${apiPrefixSlug}/auth/currentUser`, "GET"); 125 | }; 126 | 127 | export const useGetCurrentUserQuery = () => 128 | useQuery<{ is_logged_in: true; user: user }, ErrorResponse>( 129 | "getCurrentUser", 130 | getCurrentUser, 131 | { 132 | retry: false, 133 | staleTime: Infinity, 134 | cacheTime: Infinity, 135 | } 136 | ); 137 | 138 | export const logOutUser = async () => { 139 | return makePostRequest(`${apiPrefixSlug}/auth/logOut`, "GET"); 140 | }; 141 | 142 | export const sessionInstanceAddOrModifyBlock = async (payload: any) => { 143 | return makePostRequest( 144 | `${apiPrefixSlug}/sessionInstance/addOrModifyBlock`, 145 | "POST", 146 | payload 147 | ); 148 | }; 149 | 150 | export const endSessionInstance = async (activeSessionInstanceId: any) => { 151 | return makePostRequest(`${apiPrefixSlug}/sessionInstance/end`, "POST", { 152 | activeSessionInstanceId, 153 | }); 154 | }; 155 | 156 | export const changeStateOfSessionSchema = async ( 157 | payload: SessionSchema_SubmitForReview_Request 158 | ) => { 159 | return makePostRequest( 160 | `${apiPrefixSlug}/sessionSchema/submit_for_review`, 161 | "POST", 162 | payload 163 | ); 164 | }; 165 | 166 | export const getTopSessionSchema = async ( 167 | pageState: SessionSchema_Top_Request 168 | ) => { 169 | return makePostRequest( 170 | `${apiPrefixSlug}/sessionSchema/top`, 171 | "POST", 172 | pageState 173 | ); 174 | }; 175 | 176 | export const voteASessionSchema = async (payload: SessionSchemaVoteRequest) => { 177 | return makePostRequest( 178 | `${apiPrefixSlug}/sessionSchema/vote/`, 179 | "POST", 180 | payload 181 | ); 182 | }; 183 | 184 | export const getWorkoutInsights = async (payload: Insights_Workout_Request) => { 185 | return makePostRequest( 186 | `${apiPrefixSlug}/insights/workout`, 187 | "POST", 188 | payload 189 | ); 190 | }; 191 | 192 | export const getTimeSpentInsights = async () => { 193 | return makePostRequest(`${apiPrefixSlug}/insights/timeSpent`, "POST"); 194 | }; 195 | 196 | export const getUserProfile = async () => { 197 | return makePostRequest(`${apiPrefixSlug}/auth/profile`, "GET"); 198 | }; 199 | -------------------------------------------------------------------------------- /apps/frontend/components/CreateNewOrEditWorkoutModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | WorkoutFormOptionsResponse, 3 | WorkoutListResponse, 4 | Workout_AddOrModify_Request, 5 | Workout_AddOrModify_Response, 6 | } from "@sigmafit/commons"; 7 | import { Formik, Form } from "formik"; 8 | import React from "react"; 9 | import { useMutation, useQuery, useQueryClient } from "react-query"; 10 | import { toast } from "react-toastify"; 11 | import { 12 | addNewOrModifyWorkoutMutation, 13 | ErrorResponse, 14 | getNewWorkoutAddFormOptions, 15 | } from "../api"; 16 | import { FormikTextAreaField } from "./FormikTextAreaField"; 17 | import { FormSingleSelectFormikField } from "./FormSingleSelectField"; 18 | import { FormInputField } from "./InputField"; 19 | import { SigmaModal } from "./SigmaModal"; 20 | 21 | export const defaultInitialValues_WorkoutForm = { 22 | name: "", 23 | category: "", 24 | intensity: "", 25 | target_body_part: "", 26 | notes: "", 27 | workout_image_url: "", 28 | }; 29 | 30 | export const CreateNewOrEditWorkoutModal: React.FC<{ 31 | isModalOpen: boolean; 32 | initialValues?: typeof defaultInitialValues_WorkoutForm; 33 | setIsModalOpen: React.Dispatch>; 34 | existingWorkoutId?: string; 35 | }> = ({ 36 | isModalOpen, 37 | setIsModalOpen, 38 | initialValues = defaultInitialValues_WorkoutForm, 39 | existingWorkoutId, 40 | }) => { 41 | const queryClient = useQueryClient(); 42 | 43 | const isEditMode = existingWorkoutId && existingWorkoutId !== ""; 44 | 45 | // To be used only for create mode 46 | const { isLoading: addWorkoutWaitingForServerResponse, mutate } = useMutation< 47 | Workout_AddOrModify_Response, 48 | ErrorResponse, 49 | Workout_AddOrModify_Request 50 | >(addNewOrModifyWorkoutMutation, { 51 | onSuccess: (data) => { 52 | const prevWorkouts = 53 | queryClient.getQueryData("getAllWorkouts"); 54 | 55 | if (prevWorkouts) { 56 | const newWorkouts = { 57 | ...prevWorkouts, 58 | myWorkouts: isEditMode 59 | ? [ 60 | ...prevWorkouts.myWorkouts.filter( 61 | (e) => e.id !== data.workout.id 62 | ), 63 | data.workout, 64 | ] 65 | : [...prevWorkouts.myWorkouts, data.workout], 66 | }; 67 | queryClient.setQueryData("getAllWorkouts", newWorkouts); 68 | } 69 | 70 | toast(`Workout ${isEditMode ? "edited" : "added"} successfully!`, { 71 | type: "success", 72 | }); 73 | setIsModalOpen(false); 74 | }, 75 | onError: (err) => { 76 | toast(err.message, { type: "error" }); 77 | }, 78 | }); 79 | 80 | const { data: formOptions } = useQuery< 81 | WorkoutFormOptionsResponse, 82 | ErrorResponse 83 | >("getNewWorkoutAddFormOptions", getNewWorkoutAddFormOptions, { 84 | onSettled: (data, error) => { 85 | if (error) toast(error.message, { type: "error" }); 86 | }, 87 | }); 88 | 89 | const handleSubmit = async ( 90 | values: typeof defaultInitialValues_WorkoutForm 91 | ) => { 92 | mutate({ 93 | ...values, 94 | id: existingWorkoutId, // if undefined it will be removed by the helper funx in api.ts 95 | } as any); 96 | }; 97 | 98 | if (isModalOpen) { 99 | return ( 100 | <> 101 | {isModalOpen ? ( 102 | setIsModalOpen(newVal)} 105 | > 106 |
107 | 108 |
109 |
110 |

{isEditMode ? "Edit" : "Add New"} Workout

111 |
112 | 113 |
114 | We already have 100+ registered workouts, with detailed 115 | description, quick tips etc. Please ensure that there 116 | isn't an existing workout you're trying to{" "} 117 | {isEditMode ? "edit" : "add"}. 118 |
119 | 120 | 125 | 126 | ({ 132 | value: e, 133 | label: e, 134 | })) 135 | : [] 136 | } 137 | /> 138 | 139 | ({ 145 | value: e, 146 | label: e, 147 | })) 148 | : [] 149 | } 150 | /> 151 | 152 | ({ 158 | value: e, 159 | label: e, 160 | })) 161 | : [] 162 | } 163 | /> 164 | 165 | 171 | 172 | 177 | 178 |
179 | 186 |
187 | 188 |
189 |
190 |
191 | ) : null} 192 | 193 | ); 194 | } 195 | return null; 196 | }; 197 | -------------------------------------------------------------------------------- /apps/frontend/components/CustomMenuIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CustomMenuIcon = ({ className }: { className: string }) => ( 2 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /apps/frontend/components/ErrorMessageAlertBox.tsx: -------------------------------------------------------------------------------- 1 | export const ErrorMessageAlertBox = ({ children }: { children: any }) => ( 2 |
3 |
4 | 10 | 16 | 17 | 18 | {children} 19 |
20 |
21 | ); 22 | -------------------------------------------------------------------------------- /apps/frontend/components/ErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ServerErrorIllustration } from "./icons/ServerErrorIllustration"; 3 | import { Logo } from "./Logo"; 4 | import { Navbar } from "./Navbar"; 5 | 6 | export const ErrorScreen = ({ 7 | message, 8 | heading = "Server Error", 9 | }: { 10 | message?: string; 11 | heading?: string; 12 | }) => ( 13 | <> 14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 |

{heading}

22 |
23 | 24 |
25 |
26 | 32 | 38 | 39 |
40 |
41 | Something went terribly wrong. Please allow us some time to fix 42 | it. 43 |
44 |
45 |
46 | 47 | {message &&
Details: {message}
} 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | 59 | ); 60 | -------------------------------------------------------------------------------- /apps/frontend/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { SigmaFitLogoHead } from "./icons/SigmaFitLogoHead"; 3 | 4 | export const Footer = () => ( 5 |
6 |
7 |
8 | {SigmaFitLogoHead()} 9 | 10 |

11 | Powered by{" "} 12 | 13 | Hashnode 14 | {" "} 15 | and{" "} 16 | 17 | Planetscale{" "} 18 | 19 |

20 |
21 |
22 |
23 | ); 24 | -------------------------------------------------------------------------------- /apps/frontend/components/FormSingleSelectField.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage, useFormikContext } from "formik"; 2 | import Select, { ActionMeta } from "react-select"; 3 | 4 | export const FormSingleSelectFormikField = ({ 5 | fieldId, 6 | fieldLabel, 7 | options, 8 | isInline, 9 | }: { 10 | fieldId: string; 11 | fieldLabel: string; 12 | options: { value: string; label: string }[]; 13 | isInline?: boolean; 14 | }) => { 15 | const { values, setFieldValue, getFieldProps } = useFormikContext(); 16 | const value = getFieldProps(fieldId).value; 17 | 18 | return ( 19 | <> 20 | { 24 | setFieldValue(fieldId, option?.value); 25 | }} 26 | options={options} 27 | value={value} 28 | isInline={isInline} 29 | /> 30 | 31 | 32 | ); 33 | }; 34 | 35 | export const FormSingleSelectField = ({ 36 | fieldId, 37 | fieldLabel, 38 | options, 39 | isInline, 40 | onChange, 41 | value, 42 | }: { 43 | fieldId: string; 44 | fieldLabel: string; 45 | options: { value: string; label: string }[]; 46 | isInline?: boolean; 47 | onChange: 48 | | (( 49 | newValue: { value: string; label: string }, 50 | actionMeta: ActionMeta 51 | ) => void) 52 | | undefined; 53 | value: string; 54 | }) => { 55 | const getValue = () => { 56 | if (value) { 57 | return options.filter((option) => option.value === value); 58 | } else { 59 | return []; 60 | } 61 | }; 62 | return ( 63 | 115 | ) : null} 116 | 117 |
118 | {/* Print all workout blocks */} 119 | {initialValues.schema_blocks.map((e, indx) => { 120 | return ( 121 |
122 | {"workout_id" in e ? ( 123 | // workout instance 124 | 128 | ) : ( 129 | // superset schema instance 130 |
131 | 132 | 133 |
134 | {e.superset_workout_schema.map((f, index) => ( 135 | 140 | ))} 141 |
142 |
143 | )} 144 |
145 | ); 146 | })} 147 | 148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /apps/frontend/components/SigmaModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SigmaModal: React.FC<{ 4 | children: JSX.Element | JSX.Element[]; 5 | isOpen: boolean; 6 | setIsOpen: (newValue: boolean) => void; 7 | }> = ({ children, isOpen, setIsOpen }) => { 8 | if (isOpen) { 9 | return ( 10 | <> 11 |
12 |
13 |
{ 15 | setIsOpen(false); 16 | }} 17 | className="btn btn-sm btn-circle absolute right-2 top-2" 18 | > 19 | ✕ 20 |
21 | {children} 22 |
23 |
24 | 25 | ); 26 | } 27 | return null; 28 | }; 29 | -------------------------------------------------------------------------------- /apps/frontend/components/TimeSpentChart.tsx: -------------------------------------------------------------------------------- 1 | import { Insights_TimeSpent_Response } from "@sigmafit/commons"; 2 | import { 3 | Chart as ChartJS, 4 | CategoryScale, 5 | LinearScale, 6 | PointElement, 7 | LineElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | TimeScale, 12 | BarElement, 13 | TimeSeriesScale, 14 | ArcElement, 15 | RadialLinearScale, 16 | LineController, 17 | } from "chart.js"; 18 | import { Chart } from "react-chartjs-2"; 19 | 20 | import { useQuery } from "react-query"; 21 | import { ErrorResponse, getTimeSpentInsights } from "../api"; 22 | import "chartjs-adapter-date-fns"; 23 | 24 | ChartJS.register( 25 | LinearScale, 26 | PointElement, 27 | LineElement, 28 | Title, 29 | Tooltip, 30 | Legend, 31 | TimeScale, 32 | LineController 33 | ); 34 | 35 | // Only for duration 36 | export const TimeSpentChart = ({ height }: { height: number }) => { 37 | const { data: timeSpentData, isLoading } = useQuery< 38 | Insights_TimeSpent_Response, 39 | ErrorResponse 40 | >("getTimeSpentInsights", getTimeSpentInsights); 41 | const month = [ 42 | "January", 43 | "February", 44 | "March", 45 | "April", 46 | "May", 47 | "June", 48 | "July", 49 | "August", 50 | "September", 51 | "October", 52 | "November", 53 | "December", 54 | ]; 55 | return ( 56 | <> 57 | {isLoading || !timeSpentData ? ( 58 |
Loading...
59 | ) : ( 60 |
61 | { 66 | const date = new Date(e.startTime); 67 | return date; 68 | }), 69 | datasets: [ 70 | { 71 | data: timeSpentData.dataPoints.map((e) => e.duration), // in minutes 72 | cubicInterpolationMode: "monotone", 73 | borderColor: "#2563eb", 74 | }, 75 | ], 76 | }} 77 | options={{ 78 | responsive: true, 79 | maintainAspectRatio: false, 80 | plugins: { 81 | tooltip: { 82 | callbacks: { 83 | title: function (this, tooltipItem) { 84 | return `Session Name: ${ 85 | timeSpentData.dataPoints[tooltipItem[0].dataIndex] 86 | .session_name 87 | }`; 88 | }, 89 | label(this, tooltipItem) { 90 | return `Session Length: ${ 91 | timeSpentData.dataPoints[tooltipItem.dataIndex].duration 92 | } minutes`; 93 | }, 94 | }, 95 | }, 96 | legend: { 97 | display: false, 98 | }, 99 | }, 100 | 101 | scales: { 102 | x: { 103 | type: "time", 104 | time: { 105 | unit: "day", 106 | }, 107 | title: { 108 | display: false, 109 | }, 110 | }, 111 | y: { 112 | beginAtZero: true, 113 | }, 114 | }, 115 | }} 116 | /> 117 |
118 | )} 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/AnalyticsIcon.tsx: -------------------------------------------------------------------------------- 1 | export const AnalyticsIcon = ({ className }: { className: string }) => ( 2 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/CustomXIcon.tsx: -------------------------------------------------------------------------------- 1 | export const CustomXIcon = ({ className }: { className: string }) => ( 2 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/DashboardIcon.tsx: -------------------------------------------------------------------------------- 1 | export const DashboardIcon = ({ className }: { className: string }) => ( 2 | 7 | 8 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/GitHubIcon.tsx: -------------------------------------------------------------------------------- 1 | export const GitHubIcon = () => ( 2 | 3 | 4 | 5 | ); 6 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/GoogleIcon.tsx: -------------------------------------------------------------------------------- 1 | export const GoogleIcon = () => ( 2 | 8 | 12 | 16 | 20 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/MoveGrabber.tsx: -------------------------------------------------------------------------------- 1 | export const MoveGrabberIcon = ({ className }: { className: string }) => { 2 | return ( 3 | <> 4 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/SigmaFitLogoHead.tsx: -------------------------------------------------------------------------------- 1 | export function SigmaFitLogoHead() { 2 | return ( 3 | 9 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/TopWorkoutRoutinesIcon.tsx: -------------------------------------------------------------------------------- 1 | export const TopWorkoutRoutinesIcon = ({ 2 | className, 3 | }: { 4 | className: string; 5 | }) => ( 6 | 11 | 12 | 19 | 20 | 25 | 30 | 34 | 44 | 49 | 53 | 57 | 61 | 65 | 66 | 75 | 76 | 85 | 86 | 87 | 88 | ); 89 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | export const TwitterIcon = () => ( 2 | 3 | 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /apps/frontend/components/icons/WorkoutIcon.tsx: -------------------------------------------------------------------------------- 1 | export const WorkoutIcon = ({ className }: { className: string }) => ( 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 | 29 | 34 | 35 | 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /apps/frontend/hooks/useScript.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | // Credits: https://usehooks.com/useScript/ 4 | 5 | export function useScript(src: string) { 6 | // Keep track of script status ("idle", "loading", "ready", "error") 7 | const [status, setStatus] = useState(src ? "loading" : "idle"); 8 | useEffect( 9 | () => { 10 | // Allow falsy src value if waiting on other data needed for 11 | // constructing the script URL passed to this hook. 12 | if (!src) { 13 | setStatus("idle"); 14 | return; 15 | } 16 | // Fetch existing script element by src 17 | // It may have been added by another instance of this hook 18 | let script: any = document.querySelector(`script[src="${src}"]`); // TODO: remove any 19 | if (!script) { 20 | // Create script 21 | script = document.createElement("script"); 22 | script.src = src; 23 | script.async = true; 24 | script.setAttribute("data-status", "loading"); 25 | // Add script to document body 26 | document.body.appendChild(script); 27 | // Store status in attribute on script 28 | // This can be read by other instances of this hook 29 | const setAttributeFromEvent = (event: any) => { 30 | script.setAttribute( 31 | "data-status", 32 | event.type === "load" ? "ready" : "error" 33 | ); 34 | }; 35 | script.addEventListener("load", setAttributeFromEvent); 36 | script.addEventListener("error", setAttributeFromEvent); 37 | } else { 38 | // Grab existing script status from attribute and set to state. 39 | setStatus(script.getAttribute("data-status")); 40 | } 41 | // Script event handler to update status in state 42 | // Note: Even if the script already exists we still need to add 43 | // event handlers to update the state for *this* hook instance. 44 | const setStateFromEvent = (event: any) => { 45 | setStatus(event.type === "load" ? "ready" : "error"); 46 | }; 47 | // Add event listeners 48 | script.addEventListener("load", setStateFromEvent); 49 | script.addEventListener("error", setStateFromEvent); 50 | // Remove event listeners on cleanup 51 | return () => { 52 | if (script) { 53 | script.removeEventListener("load", setStateFromEvent); 54 | script.removeEventListener("error", setStateFromEvent); 55 | } 56 | }; 57 | }, 58 | [src] // Only re-run effect if script src changes 59 | ); 60 | return status; 61 | } 62 | -------------------------------------------------------------------------------- /apps/frontend/hooks/withAuthHOC.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import React, { PropsWithChildren } from "react"; 3 | import { useQuery } from "react-query"; 4 | import { useGetCurrentUserQuery } from "../api"; 5 | import { ErrorScreen } from "../components/ErrorScreen"; 6 | 7 | export const withAuthHOC = (WrappedComponent: any): React.FC => { 8 | return () => { 9 | const { isLoading, isError, error, data } = useGetCurrentUserQuery(); 10 | const router = useRouter(); 11 | 12 | if (isLoading) { 13 | return null; 14 | } else if (isError) { 15 | return ; 16 | } else if (data?.is_logged_in) { 17 | return ; 18 | } else { 19 | router.push("/"); 20 | return null; 21 | } 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/frontend/hooks/withNoAuthHOC.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import React from "react"; 3 | import { useGetCurrentUserQuery } from "../api"; 4 | import { ErrorScreen } from "../components/ErrorScreen"; 5 | import { Navbar } from "../components/Navbar"; 6 | 7 | export const witNoAuthHOC = (WrappedComponent: any): React.FC => { 8 | return () => { 9 | const { isLoading, isError, error, data } = useGetCurrentUserQuery(); 10 | const router = useRouter(); 11 | 12 | if (isLoading) { 13 | return null; 14 | } else if (isError) { 15 | return ; 16 | } else { 17 | if (data?.is_logged_in) { 18 | // user logged in 19 | router.push("/dash"); 20 | } else { 21 | return ; 22 | } 23 | return null; 24 | } 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | async rewrites() { 5 | return [ 6 | { 7 | source: "/api/:slug*", 8 | destination: `${process.env.SERVER_URL}/api/:slug*`, // Proxy to Backend 9 | }, 10 | ]; 11 | }, 12 | }; 13 | const withPWA = require("next-pwa"); 14 | 15 | module.exports = withPWA({ 16 | ...nextConfig, 17 | pwa: { 18 | dest: "./public", 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sigmafit/frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint && tsc --noEmit", 10 | "format": "prettier --write . --ignore-path .gitignore" 11 | }, 12 | "dependencies": { 13 | "@headlessui/react": "^1.6.6", 14 | "@heroicons/react": "^1.0.6", 15 | "@tailwindcss/typography": "^0.5.2", 16 | "axios": "^0.27.2", 17 | "chart.js": "^3.8.0", 18 | "chartjs-adapter-date-fns": "^2.0.0", 19 | "daisyui": "^2.17.0", 20 | "date-fns": "^2.28.0", 21 | "formik": "^2.2.9", 22 | "next": "12.2.0", 23 | "next-pwa": "5.4.0", 24 | "react": "18.2.0", 25 | "react-chartjs-2": "^4.3.1", 26 | "react-dom": "18.2.0", 27 | "react-draggable": "^4.4.5", 28 | "react-markdown": "^8.0.3", 29 | "react-query": "^3.39.1", 30 | "react-select": "^5.3.2", 31 | "react-sortablejs": "^6.1.4", 32 | "react-toastify": "^9.0.5", 33 | "sortablejs": "^1.15.0", 34 | "yup": "^0.32.11" 35 | }, 36 | "devDependencies": { 37 | "@sigmafit/commons": "1.0.0", 38 | "@types/node": "18.0.0", 39 | "@types/react": "^18.0.15", 40 | "@types/react-dom": "^18.0.6", 41 | "@types/sortablejs": "^1.13.0", 42 | "autoprefixer": "^10.4.7", 43 | "eslint": "8.18.0", 44 | "eslint-config-next": "12.2.0", 45 | "postcss": "^8.4.14", 46 | "prettier": "^2.7.1", 47 | "tailwindcss": "^3.1.4", 48 | "typescript": "4.7.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { QueryClient, QueryClientProvider } from "react-query"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | import { ToastContainer } from "react-toastify"; 6 | 7 | // Create a client 8 | const queryClient = new QueryClient(); 9 | 10 | function MyApp({ Component, pageProps }: AppProps) { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default MyApp; 22 | -------------------------------------------------------------------------------- /apps/frontend/pages/auth/logout.tsx: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import { useQuery, useQueryClient } from "react-query"; 3 | import { toast } from "react-toastify"; 4 | import { ErrorResponse, logOutUser } from "../../api"; 5 | 6 | const LogOut = () => { 7 | const queryClient = useQueryClient(); 8 | 9 | useQuery("logOutUser", logOutUser, { 10 | onSettled: (data, error: ErrorResponse | null) => { 11 | if (error) toast(error.message, { type: "error" }); 12 | else toast(data?.message, { type: "success" }); 13 | 14 | queryClient.refetchQueries("getCurrentUser"); 15 | 16 | Router.push("/"); 17 | }, 18 | }); 19 | 20 | return null; 21 | }; 22 | 23 | export default LogOut; 24 | -------------------------------------------------------------------------------- /apps/frontend/pages/auth/welcome.tsx: -------------------------------------------------------------------------------- 1 | import { MetaHead } from "../../components/Head"; 2 | import { LogoWithoutBeta } from "../../components/LogoWithoutBeta"; 3 | import { witNoAuthHOC } from "../../hooks/withNoAuthHOC"; 4 | import { TwitterIcon } from "../../components/icons/TwitterIcon"; 5 | import { GitHubIcon } from "../../components/icons/GitHubIcon"; 6 | import { GoogleIcon } from "../../components/icons/GoogleIcon"; 7 | 8 | const Welcome = () => { 9 | return ( 10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 |
18 |

Welcome Warriors!

19 |
20 | 21 |
22 | Unleash the{" "} 23 | 24 | true power 25 | {" "} 26 | of this platform by logging in: 27 |
28 | 29 |
30 |
(window.location.href = `/api/auth/google/start`)} 32 | className="bg-white h-fit text-black hover:text-white btn rounded-md shadow-xl py-3 flex justify-center items-center gap-2" 33 | > 34 | 35 | 36 | {" "} 37 | Continue with Google 38 |
39 |
(window.location.href = `/api/auth/github/start`)} 41 | className="bg-white h-fit text-black hover:text-white btn rounded-md shadow-xl py-3 hover:fill-white flex justify-center items-center gap-2" 42 | > 43 | 44 | 45 | {" "} 46 | Continue with GitHub 47 |
48 |
(window.location.href = `/api/auth/twitter/start`)} 50 | className="bg-white h-fit text-black hover:text-white btn rounded-md shadow-xl py-3 hover:fill-white flex justify-center items-center gap-2" 51 | > 52 | 53 | 54 | {" "} 55 | Continue with Twitter 56 |
57 | 58 |
59 | By logging in you accept our Privacy Policy and Terms of Service. 60 |
61 |
62 |
63 |
64 | ); 65 | }; 66 | 67 | export default witNoAuthHOC(Welcome); 68 | -------------------------------------------------------------------------------- /apps/frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import Link from "next/link"; 3 | import { useGetCurrentUserQuery } from "../api"; 4 | import { MetaHead } from "../components/Head"; 5 | import { Navbar } from "../components/Navbar"; 6 | import { witNoAuthHOC } from "../hooks/withNoAuthHOC"; 7 | import { PhoneMock } from "../components/PhoneMock"; 8 | import { Footer } from "../components/Footer"; 9 | 10 | const Home: NextPage = () => { 11 | const { isError, isLoading, data } = useGetCurrentUserQuery(); 12 | 13 | return ( 14 |
15 | 16 | 17 |
18 | 19 | {/* */} 20 |
21 |
22 |

23 | Workout tracking made easy. 24 |

25 |

26 | Track your progress at gym hassle free. And use our personalized 27 | insights to improve your fitness journey. 28 |

29 | {!isLoading && data?.is_logged_in ? ( 30 | 31 | 34 | 35 | ) : ( 36 | 37 | 40 | 41 | )} 42 |
43 | 44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |

58 | Track Everything 59 |

60 |
61 | Log all your workouts on SigmaFit. It's simpler and more rugged 62 | than any notebook out there. It will help you with the planning, 63 | execution and tracking of progress. 64 |
65 |
66 |
67 | 68 |
69 |
70 | 71 |
72 |
73 | 74 |
75 |
76 |

Wide support

77 |
78 | SigmaFit lets you log the thing you want to track. You can track 79 | distance, duration, weight, reps, and anything based on your 80 | workout requirements. 81 |
82 |
83 |
84 | 85 |
86 |
87 |

88 | Fully customizable 89 |

90 |
91 | In SigmaFit, it's super easy to get started with community 92 | Training Routines. If you have a custom workout plan, you can 93 | create that too. SigmaFit is super customizable, and creating a 94 | training routine is a cakewalk. 95 |
96 |
97 |
98 | 103 |
104 |
105 | 106 |
107 |
108 | 109 |
110 |
111 |

Record History

112 |
113 | Personal Records are not just any numbers. They're very special 114 | for every athlete out there. SigmaFit keeps track of it and 115 | provides extra motivation to make it even bigger! 116 |
117 |
118 |
119 | 120 |
121 |
122 |

123 | Sharing is caring 124 |

125 |
126 | If you've created a training routine you're particularly proud 127 | of, you can share it with the community and friends. 128 |
129 |
130 | 131 |
132 | 133 |
134 |
135 | 136 |
137 |
138 | 139 |
140 | 141 |
142 |

143 | Available on all devices 144 |

145 |
146 | SigmaFit is a progressive web app. You can run it on any browser 147 | environment. It takes lesser resources and blazingly fast. 148 |
149 |
150 |
151 |
152 |
153 | 154 |
155 |
156 | ); 157 | }; 158 | export default witNoAuthHOC(Home); 159 | -------------------------------------------------------------------------------- /apps/frontend/pages/profile.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, XIcon } from "@heroicons/react/solid"; 2 | import { user } from "@sigmafit/commons/dist/prismaGenTypes"; 3 | import { useQuery } from "react-query"; 4 | import { ErrorResponse, getUserProfile, useGetCurrentUserQuery } from "../api"; 5 | import { Footer } from "../components/Footer"; 6 | import { MetaHead } from "../components/Head"; 7 | import { Navbar } from "../components/Navbar"; 8 | import { withAuthHOC } from "../hooks/withAuthHOC"; 9 | import { DescriptionText } from "../components/RenderWorkoutView"; 10 | 11 | const Profile = () => { 12 | const { data, isLoading } = useQuery( 13 | "getUserProfile", 14 | getUserProfile 15 | ); // no need to check for loading and all 16 | 17 | return ( 18 |
19 | 20 | 21 | 22 |
23 | {isLoading || !data ? ( 24 |
Loading
25 | ) : ( 26 |
27 |
28 |

Profile

29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 |
38 |
39 |
40 | 41 |
42 | 48 | 49 | 55 | 56 | 62 | 63 | 69 | 70 | 75 | ) : ( 76 | 77 | ) 78 | } 79 | type="justify-between" 80 | size="med" 81 | /> 82 | 87 | ) : ( 88 | 89 | ) 90 | } 91 | type="justify-between" 92 | size="med" 93 | /> 94 | 99 | ) : ( 100 | 101 | ) 102 | } 103 | type="justify-between" 104 | size="med" 105 | /> 106 |
107 |
108 | )} 109 |
110 | 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default withAuthHOC(Profile); 117 | -------------------------------------------------------------------------------- /apps/frontend/pages/sessionSchema/[id]/clone.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useMutation, useQuery } from "react-query"; 3 | import { toast } from "react-toastify"; 4 | import { 5 | addNewSessionSchema, 6 | ErrorResponse, 7 | getSessionSchemaDetails, 8 | } from "../../../api"; 9 | import { MetaHead } from "../../../components/Head"; 10 | import { Navbar } from "../../../components/Navbar"; 11 | import SessionSchemaForm, { 12 | SessionSchemaFormValueType, 13 | } from "../../../components/Forms/SessionSchemaForm"; 14 | import { 15 | SessionSchemaCreateRequest, 16 | SessionSchemaCreateResponse, 17 | SessionSchemaDetailsResponse, 18 | } from "@sigmafit/commons"; 19 | import { withAuthHOC } from "../../../hooks/withAuthHOC"; 20 | import { Footer } from "../../../components/Footer"; 21 | 22 | const SessionSchemaClone = () => { 23 | const router = useRouter(); 24 | const { id } = router.query; 25 | 26 | const { isLoading, data } = useQuery< 27 | SessionSchemaDetailsResponse, 28 | ErrorResponse 29 | >( 30 | ["getSessionSchemaDetails", id], 31 | () => getSessionSchemaDetails(id as string), 32 | { 33 | enabled: !!id, 34 | onSettled: (data, error) => { 35 | if (error) toast(error.message, { type: "error" }); 36 | }, 37 | } 38 | ); 39 | 40 | const { mutate } = useMutation< 41 | SessionSchemaCreateResponse, 42 | ErrorResponse, 43 | SessionSchemaCreateRequest 44 | >("addNewSessionSchema", addNewSessionSchema, { 45 | onSettled(data, error) { 46 | if (error) { 47 | toast(error.message, { type: "error" }); 48 | } else if (data) { 49 | toast("successfully cloned the workout routine"); 50 | router.push("/dash"); 51 | } 52 | }, 53 | }); 54 | 55 | const handleSubmit = (payload: SessionSchemaFormValueType) => { 56 | mutate({ 57 | session_name: payload.session_name, 58 | schema_blocks: payload.schema_blocks.map((block, indx) => { 59 | if ("workout_id" in block) { 60 | return { ...block, order: indx }; 61 | } else { 62 | return { 63 | ...block, 64 | superset_workout_schema: block.superset_workout_schema.map( 65 | (workout, indx) => { 66 | return { ...workout, order: indx }; 67 | } 68 | ), 69 | order: indx, 70 | }; 71 | } 72 | }), 73 | }); 74 | }; 75 | 76 | return ( 77 |
78 | 79 |
80 | 81 | 82 |
83 | {isLoading || !data ? ( 84 |
Loading...
85 | ) : ( 86 | 92 | )} 93 |
94 |
95 |
96 |
97 | ); 98 | }; 99 | 100 | export default withAuthHOC(SessionSchemaClone); 101 | -------------------------------------------------------------------------------- /apps/frontend/pages/sessionSchema/[id]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useQuery } from "react-query"; 3 | import { toast } from "react-toastify"; 4 | import { ErrorResponse, getSessionSchemaDetails } from "../../../api"; 5 | import { MetaHead } from "../../../components/Head"; 6 | import { Navbar } from "../../../components/Navbar"; 7 | import SessionSchemaForm, { 8 | SessionSchemaFormValueType, 9 | } from "../../../components/Forms/SessionSchemaForm"; 10 | import { SessionSchemaDetailsResponse } from "@sigmafit/commons"; 11 | import { withAuthHOC } from "../../../hooks/withAuthHOC"; 12 | import { Footer } from "../../../components/Footer"; 13 | 14 | // TODO: Currently we're using it as a way to show the data; editing is not allowed for now 15 | const SessionSchemaEdit = () => { 16 | const router = useRouter(); 17 | const { id } = router.query; 18 | 19 | const { isLoading, data } = useQuery< 20 | SessionSchemaDetailsResponse, 21 | ErrorResponse 22 | >( 23 | ["getSessionSchemaDetails", id], 24 | () => getSessionSchemaDetails(id as string), 25 | { 26 | enabled: !!id, 27 | onSettled: (data, error) => { 28 | if (error) toast(error.message, { type: "error" }); 29 | }, 30 | } 31 | ); 32 | 33 | const handleSubmit = (payload: SessionSchemaFormValueType) => { 34 | toast("Editing form is currently disabled..", { type: "error" }); 35 | }; 36 | 37 | return ( 38 |
39 | 40 |
41 | 42 | 43 |
44 | {isLoading || !data ? ( 45 |
Loading...
46 | ) : ( 47 | 53 | )} 54 |
55 | 56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | export default withAuthHOC(SessionSchemaEdit); 63 | -------------------------------------------------------------------------------- /apps/frontend/pages/sessionSchema/[id]/view.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useQuery } from "react-query"; 3 | import { toast } from "react-toastify"; 4 | import { ErrorResponse, getSessionSchemaDetails } from "../../../api"; 5 | import { MetaHead } from "../../../components/Head"; 6 | import { Navbar } from "../../../components/Navbar"; 7 | import { SessionSchemaFormValueType } from "../../../components/Forms/SessionSchemaForm"; 8 | import { SessionSchemaDetailsResponse } from "@sigmafit/commons"; 9 | import { withAuthHOC } from "../../../hooks/withAuthHOC"; 10 | import { Footer } from "../../../components/Footer"; 11 | import { SessionSchemaView } from "../../../components/SessionSchemaView"; 12 | 13 | // TODO: Currently we're using it as a way to show the data; editing is not allowed for now 14 | const SessionSchemaEdit = () => { 15 | const router = useRouter(); 16 | 17 | const { id } = router.query; 18 | 19 | const { isLoading, data } = useQuery< 20 | SessionSchemaDetailsResponse, 21 | ErrorResponse 22 | >( 23 | ["getSessionSchemaDetails", id], 24 | () => getSessionSchemaDetails(id as string), 25 | { 26 | enabled: !!id, 27 | onSettled: (_, error) => { 28 | if (error) toast(error.message, { type: "error" }); 29 | }, 30 | } 31 | ); 32 | 33 | const handleSubmit = (payload: SessionSchemaFormValueType) => { 34 | toast("Editing form is currently disabled..", { type: "error" }); 35 | }; 36 | 37 | return ( 38 |
39 | 40 | 41 | 42 | 43 |
44 | {isLoading || !data ? ( 45 |
Loading...
46 | ) : ( 47 | 54 | )} 55 |
56 | 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default withAuthHOC(SessionSchemaEdit); 63 | -------------------------------------------------------------------------------- /apps/frontend/pages/sessionSchema/new.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useMutation } from "react-query"; 3 | import { toast } from "react-toastify"; 4 | import { addNewSessionSchema, ErrorResponse } from "../../api"; 5 | import { MetaHead } from "../../components/Head"; 6 | import { Navbar } from "../../components/Navbar"; 7 | import SessionSchemaForm, { 8 | SessionSchemaFormValueType, 9 | } from "../../components/Forms/SessionSchemaForm"; 10 | import { SessionSchemaCreateRequest } from "@sigmafit/commons"; 11 | import { withAuthHOC } from "../../hooks/withAuthHOC"; 12 | import { Footer } from "../../components/Footer"; 13 | 14 | const AddSessionSchema = () => { 15 | const { 16 | isLoading: waitingForServerResponse, 17 | mutate, 18 | error, 19 | data, 20 | } = useMutation( 21 | addNewSessionSchema 22 | ); 23 | const router = useRouter(); 24 | 25 | const handleSubmit = (values: SessionSchemaFormValueType) => { 26 | mutate( 27 | { 28 | session_name: values.session_name, 29 | schema_blocks: values.schema_blocks.map((block, indx) => { 30 | if ("workout_id" in block) { 31 | return { ...block, order: indx }; 32 | } else { 33 | return { 34 | ...block, 35 | superset_workout_schema: block.superset_workout_schema.map( 36 | (workout, indx) => { 37 | return { ...workout, order: indx }; 38 | } 39 | ), 40 | order: indx, 41 | }; 42 | } 43 | }), 44 | }, 45 | { 46 | onSettled(data, error, variables, context) { 47 | if (error) { 48 | toast(error.message, { 49 | type: "error", 50 | }); 51 | } else { 52 | toast("Workout Routine added successfully.", { 53 | type: "success", 54 | }); 55 | router.push("/dash"); 56 | // TODO: refetch all sessions 57 | } 58 | }, 59 | } 60 | ); 61 | }; 62 | 63 | return ( 64 |
65 | 66 |
67 | 68 |
69 | 78 |
79 | 80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default withAuthHOC(AddSessionSchema); 87 | -------------------------------------------------------------------------------- /apps/frontend/pages/sessionSchema/top.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowLeftIcon, 3 | ArrowRightIcon, 4 | ClipboardCopyIcon, 5 | ShareIcon, 6 | ThumbUpIcon, 7 | } from "@heroicons/react/solid"; 8 | import { 9 | SessionSchemaVoteRequest, 10 | SessionSchemaVoteResponse, 11 | SessionSchema_Top_Request, 12 | SessionSchema_Top_Response, 13 | } from "@sigmafit/commons"; 14 | import Link from "next/link"; 15 | import { useState } from "react"; 16 | import { useMutation, useQuery, useQueryClient } from "react-query"; 17 | import { toast } from "react-toastify"; 18 | import { 19 | ErrorResponse, 20 | getTopSessionSchema, 21 | voteASessionSchema, 22 | } from "../../api"; 23 | import { Footer } from "../../components/Footer"; 24 | import { MetaHead } from "../../components/Head"; 25 | import { Navbar } from "../../components/Navbar"; 26 | import { withAuthHOC } from "../../hooks/withAuthHOC"; 27 | import { DescriptionText } from "../../components/RenderWorkoutView"; 28 | 29 | const TopSessionSchema = () => { 30 | const [cursorIdArr, setCursorIdArr] = useState< 31 | SessionSchema_Top_Request["cursor_id"][] 32 | >([null]); 33 | const [currentPageIndex, setCurrentPageIndex] = useState(0); 34 | 35 | const { isLoading, data } = useQuery< 36 | SessionSchema_Top_Response, 37 | ErrorResponse 38 | >(["getTopSessionSchema", cursorIdArr[currentPageIndex]], () => 39 | getTopSessionSchema({ cursor_id: cursorIdArr[currentPageIndex] }) 40 | ); 41 | 42 | const client = useQueryClient(); 43 | const { 44 | mutate, 45 | isLoading: waitingForServerResponseForVote, 46 | variables, 47 | } = useMutation< 48 | SessionSchemaVoteResponse, 49 | ErrorResponse, 50 | SessionSchemaVoteRequest 51 | >("voteASessionSchema", voteASessionSchema, { 52 | onSuccess: () => { 53 | client.refetchQueries("getTopSessionSchema"); 54 | }, 55 | }); 56 | 57 | return ( 58 |
59 | 60 | 61 | 62 |
63 |

Top workout routines

64 | 65 | {isLoading || !data ? ( 66 |
Loading..
67 | ) : data.results.length ? ( 68 |
69 | {data.results.map((e) => { 70 | const isVoted = e.session_schema_vote_by_user.length; 71 | return ( 72 |
73 | {/* have a card to show top workouts */} 74 | 75 |
76 | 77 |
78 | {e.name} 79 |
80 | 81 | 85 | 89 | 93 | 97 | 101 | 102 |
103 | 122 |
{ 125 | toast("Link successfully copied to clipboard", { 126 | type: "info", 127 | }); 128 | const link = `${window.location.origin}/sessionSchema/${e.id}/view`; 129 | navigator.clipboard.writeText(link); 130 | }} 131 | > 132 | Share 133 |
134 | 135 |
136 | {" "} 137 | Clone 138 |
139 | 140 |
141 |
142 |
143 | ); 144 | })} 145 | 146 |
147 | 158 | 169 |
170 |
171 | ) : ( 172 |
No data
173 | )} 174 |
175 | 176 |
177 |
178 | ); 179 | }; 180 | 181 | export default withAuthHOC(TopSessionSchema); 182 | -------------------------------------------------------------------------------- /apps/frontend/pages/workout/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | WorkoutListResponse, 3 | Workout_Delete_Response, 4 | } from "@sigmafit/commons"; 5 | import { useState } from "react"; 6 | import { useMutation, useQuery, useQueryClient } from "react-query"; 7 | import { toast } from "react-toastify"; 8 | import { deleteWorkout, ErrorResponse, getAllWorkouts } from "../../api"; 9 | import { 10 | CreateNewOrEditWorkoutModal, 11 | defaultInitialValues_WorkoutForm, 12 | } from "../../components/CreateNewOrEditWorkoutModal"; 13 | import { MetaHead } from "../../components/Head"; 14 | import { Navbar } from "../../components/Navbar"; 15 | import { withAuthHOC } from "../../hooks/withAuthHOC"; 16 | import { Footer } from "../../components/Footer"; 17 | import { RenderWorkoutsList } from "../../components/RenderWorkoutsList"; 18 | 19 | const Workouts = () => { 20 | const { data, isLoading } = useQuery( 21 | "getAllWorkouts", 22 | getAllWorkouts, 23 | { 24 | onSettled: (data, error) => { 25 | if (error) toast(error.message, { type: "error" }); 26 | }, 27 | } 28 | ); 29 | 30 | const [isModalOpen, setIsModalOpen] = useState(false); 31 | 32 | const queryClient = useQueryClient(); 33 | 34 | const { 35 | mutate, 36 | isLoading: waitingForServerResponseForDeleteWorkout, 37 | variables, 38 | } = useMutation( 39 | "deleteWorkout", 40 | deleteWorkout, 41 | { 42 | onError(error) { 43 | toast(error.message, { type: "error" }); 44 | }, 45 | onSuccess(data) { 46 | const workouts = 47 | queryClient.getQueryData("getAllWorkouts"); 48 | if (workouts) { 49 | queryClient.setQueriesData("getAllWorkouts", { 50 | publicWorkouts: workouts.publicWorkouts, 51 | myWorkouts: workouts.myWorkouts.filter( 52 | (e) => e.id !== data.deleted_workout_id 53 | ), 54 | }); 55 | } 56 | 57 | toast(data.message, { type: "success" }); 58 | }, 59 | } 60 | ); 61 | 62 | const [workoutInitialData, setWorkoutInitialData] = useState({ 63 | initialValues: defaultInitialValues_WorkoutForm, 64 | existingWorkoutId: "", 65 | }); 66 | 67 | return ( 68 |
69 | 70 | 71 | 72 |
73 |

My workouts

74 | 86 | 87 | {isLoading && ( 88 |
Loading workouts...
89 | )} 90 | {data ? ( 91 | { 93 | if ( 94 | confirm( 95 | "Are you sure you want to delete? This action is irreversible." 96 | ) 97 | ) { 98 | mutate(workout_id); 99 | } 100 | }} 101 | workouts={data.myWorkouts} 102 | handleEdit={(workout) => { 103 | setWorkoutInitialData({ 104 | initialValues: { 105 | category: workout.category ?? "", 106 | intensity: workout.intensity ?? "", 107 | name: workout.name ?? "", 108 | target_body_part: workout.target_body_part ?? "", 109 | notes: workout.notes ?? "", 110 | workout_image_url: workout.workout_image_url ?? "", 111 | }, 112 | existingWorkoutId: workout.id, 113 | }); 114 | setIsModalOpen(true); 115 | }} 116 | /> 117 | ) : null} 118 | 119 |

Public Workouts

120 | 121 | {data ? : null} 122 |
123 | 124 | 130 |
131 |
132 | ); 133 | }; 134 | 135 | export default withAuthHOC(Workouts); 136 | -------------------------------------------------------------------------------- /apps/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/frontend/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2d89ef 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/icons/mstile-150x150.png -------------------------------------------------------------------------------- /apps/frontend/public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /apps/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SigmaFit", 3 | "short_name": "SigmaFit", 4 | "theme_color": "#f9f8f0", 5 | "background_color": "#f9f8f0", 6 | "display": "standalone", 7 | "scope": "/", 8 | "start_url": "/", 9 | "icons": [ 10 | { 11 | "src": "/icons/android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /apps/frontend/public/mocks/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/0.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/1.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/2.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/3.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/4.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/5.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/6.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/7.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/8.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/9.png -------------------------------------------------------------------------------- /apps/frontend/public/mocks/pwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SigmaFitness/sigmafit/4a594ab040475c07357b14c3714e5fb61373ce21/apps/frontend/public/mocks/pwa.png -------------------------------------------------------------------------------- /apps/frontend/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .form-container { 6 | width: 94%; 7 | @apply relative border-4 border-green-800 p-4 mx-auto max-w-lg text-left; 8 | } 9 | 10 | .form-container:before { 11 | background: #2c9382; 12 | content: ""; 13 | display: block; 14 | position: absolute; 15 | margin-left: -15px; 16 | bottom: -15px; 17 | left: -4px; 18 | top: 15px; 19 | width: 15px; 20 | } 21 | .form-container:after { 22 | background: #2c9382; 23 | content: ""; 24 | display: block; 25 | position: absolute; 26 | margin-left: -15px; 27 | bottom: -19px; 28 | left: -4px; 29 | right: 15px; 30 | height: 15px; 31 | } 32 | 33 | .landing-hero { 34 | min-height: 100vh; 35 | /* color: white !important; */ 36 | /* background-image: url(https://unsplash.com/photos/doGGZWPdmQA/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8ODd8fG1vdGl2YXRpb24lMjBtb3VudGFpbnxlbnwwfHx8fDE2NTY4NDMxNDE&force=true&w=2400); */ 37 | 38 | /* background-image: linear-gradient(180deg, rgba(10, 39, 51, 0.8), rgba(10, 39, 51, 0.8)), url('https://unsplash.com/photos/MsCgmHuirDo/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8OXx8Z3ltfGVufDB8fHx8MTY1NjgxMTYwOQ&force=true&w=1920'); */ 39 | } 40 | 41 | .input-group :last-child { 42 | border-top-left-radius: inherit; 43 | border-top-right-radius: inherit; 44 | border-bottom-left-radius: inherit; 45 | border-bottom-right-radius: inherit; 46 | } 47 | 48 | .input-group :first-child { 49 | border-top-left-radius: inherit; 50 | border-top-right-radius: inherit; 51 | border-bottom-left-radius: inherit; 52 | border-bottom-right-radius: inherit; 53 | } 54 | 55 | .selectContainerWrapper { 56 | @apply font-medium text-xs; 57 | } 58 | .selectContainerWrapper .react-select__control, 59 | .selectContainerWrapper .react-select__menu, 60 | .selectContainerWrapper .react-select__menu-list { 61 | @apply bg-slate-50; 62 | } 63 | 64 | .selectContainerWrapper .react-select__control { 65 | outline: 0 !important; 66 | border-radius: 0 !important; 67 | border: 1px solid black !important; 68 | } 69 | 70 | .selectContainerWrapper:focus, 71 | .selectContainerWrapper:focus-visible { 72 | outline: 0 !important; 73 | outline-width: 0 !important; 74 | outline-style: none; 75 | } 76 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./components/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | screens: { 10 | xs: "460px", 11 | xss: "300px", 12 | }, 13 | }, 14 | }, 15 | plugins: [require("daisyui"), require("@tailwindcss/typography")], 16 | 17 | daisyui: { 18 | themes: [ 19 | { 20 | mytheme: { 21 | primary: "#131517", 22 | "primary-content": "#fff", 23 | 24 | secondary: "#de3163", 25 | "secondary-content": "#fff", 26 | 27 | accent: "#3a86ff", 28 | "accent-content": "#fff", 29 | 30 | neutral: "#191D24", 31 | "neutral-content": "#fff", 32 | "neutral-dark": "131517", 33 | 34 | "base-100": "#f9f7ef", 35 | "base-200": "#FAC5FA", 36 | // "base-300": "#FFB7FF", 37 | 38 | info: "#3ABFF8", 39 | 40 | success: "#36D399", 41 | 42 | warning: "#FBBD23", 43 | 44 | error: "#F87272", 45 | }, 46 | }, 47 | ], 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | // "paths": { 18 | // "@coreTypes/*": [ 19 | // "../types/*" 20 | // ] 21 | // }, 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "apps/*" 5 | ], 6 | "nohoist": [ 7 | "**/apps", 8 | "**/apps/**" 9 | ], 10 | "scripts": { 11 | "build": "yarn --cwd apps/commons build && (yarn --cwd apps/backend build & yarn --cwd apps/frontend build)", 12 | "lint": "yarn --cwd apps/commons lint && yarn --cwd apps/frontend lint && yarn --cwd apps/backend lint", 13 | "format": "yarn workspaces run format", 14 | "clean": "rm -rf apps/commons/dist/ lint && rm -rf apps/backend/dist/ lint && rm -rf apps/frontend/.next/", 15 | "dev": "(yarn workspace @sigmafit/commons watch & yarn workspace @sigmafit/backend dev & yarn workspace @sigmafit/frontend dev)" 16 | } 17 | } 18 | --------------------------------------------------------------------------------