├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLinters │ └── eslint.xml ├── modules.xml ├── prettier.xml ├── sweprojects.iml └── vcs.xml ├── README.md ├── assets ├── SWEProjects-logo-large.png ├── SWEProjects-logo-only.png └── SWEProjects-logo-small.png ├── emails ├── ProjectPurchaseEmail.tsx └── WelcomeEmail.tsx ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── prisma └── schema.prisma ├── projects ├── FlappyBirdClone │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── scores.ts │ │ └── index.tsx │ ├── postcss.config.js │ ├── public │ │ ├── favicon.ico │ │ ├── next.svg │ │ └── vercel.svg │ ├── styles │ │ └── globals.css │ ├── tailwind.config.js │ └── tsconfig.json └── LinkTreeClone │ ├── .eslintrc.json │ ├── .gitignore │ ├── Components │ ├── Account │ │ └── UsernameEditor.tsx │ ├── Setup │ │ ├── LinksPreviewComponent.tsx │ │ └── LinksSetupComponent.tsx │ └── common │ │ ├── BackgroundGradient.tsx │ │ └── Header.tsx │ ├── README.md │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ ├── [username].tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── account.tsx │ ├── api │ │ ├── links.ts │ │ ├── usernames.ts │ │ └── users.ts │ ├── auth.tsx │ ├── index.tsx │ └── setup.tsx │ ├── postcss.config.js │ ├── public │ ├── favicon.ico │ ├── next.svg │ └── vercel.svg │ ├── styles │ └── globals.css │ ├── tailwind.config.js │ ├── tsconfig.json │ └── types │ └── supabase.ts ├── public └── favicon.ico ├── src ├── components │ ├── Common │ │ ├── BackgroundGradient.tsx │ │ ├── Header.tsx │ │ ├── LoadingSpinner.tsx │ │ ├── MdEditor │ │ │ ├── MdEditor.tsx │ │ │ └── utils.tsx │ │ ├── PhotoUploadComponent.tsx │ │ └── Selectors.tsx │ ├── DraftProjects │ │ ├── DraftCodeBlocks.tsx │ │ ├── DraftInstructionalTextComponent.tsx │ │ ├── DraftPageComponent.tsx │ │ ├── Editor.tsx │ │ ├── FocusedQuestion.tsx │ │ └── InstructionLeftSidebar.tsx │ ├── Images │ │ ├── Celebration.tsx │ │ ├── EmptyCart.tsx │ │ ├── EmptyComments.tsx │ │ ├── EndOfTheRoad.tsx │ │ ├── PersonCoding.tsx │ │ └── Questions.tsx │ ├── LandingPage │ │ ├── AuthLayout.tsx │ │ ├── Button.tsx │ │ ├── CallToAction.tsx │ │ ├── Container.tsx │ │ ├── Faqs.tsx │ │ ├── Fields.tsx │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Hero.tsx │ │ ├── Logo.tsx │ │ ├── NavLink.tsx │ │ ├── Pricing.tsx │ │ ├── PrimaryFeatures.tsx │ │ ├── SecondaryFeatures.tsx │ │ ├── Testimonials.tsx │ │ └── images │ │ │ ├── avatars │ │ │ ├── avatar-1.png │ │ │ ├── avatar-2.png │ │ │ ├── avatar-3.png │ │ │ ├── avatar-4.png │ │ │ ├── avatar-5.png │ │ │ └── avatar-6.png │ │ │ ├── background-auth.jpg │ │ │ ├── background-call-to-action.jpg │ │ │ ├── background-faqs.jpg │ │ │ ├── background-features.jpg │ │ │ ├── logos │ │ │ ├── laravel.svg │ │ │ ├── mirage.svg │ │ │ ├── statamic.svg │ │ │ ├── statickit.svg │ │ │ ├── transistor.svg │ │ │ └── tuple.svg │ │ │ └── screenshots │ │ │ ├── canny-screenshot.png │ │ │ ├── contacts.png │ │ │ ├── expenses.png │ │ │ ├── instructions-screenshot.png │ │ │ ├── inventory.png │ │ │ ├── payroll.png │ │ │ ├── profit-loss.png │ │ │ ├── reporting.png │ │ │ └── vat-returns.png │ ├── ProjectsV2 │ │ ├── CodeBlocks.tsx │ │ ├── CodeDiffSection.tsx │ │ ├── CompletedTutorial.tsx │ │ ├── InstructionSidebar.tsx │ │ ├── InstructionsToolbar.tsx │ │ ├── ProjectTitleBlock.tsx │ │ ├── PurchaseNudge.tsx │ │ ├── QuestionsPurchaseNudge.tsx │ │ ├── TableOfContentBlock.tsx │ │ └── TextExplanationComponent.tsx │ └── QuestionsAndAnswers │ │ ├── CommentBox.tsx │ │ ├── CommentsList.tsx │ │ ├── QuestionAndAnswerComponent.tsx │ │ ├── QuestionBox.tsx │ │ └── QuestionsList.tsx ├── env.mjs ├── middleware.ts ├── pages │ ├── _app.tsx │ ├── api │ │ ├── checkout_sessions.ts │ │ ├── trpc │ │ │ └── [trpc].ts │ │ └── webhooks │ │ │ ├── clerk │ │ │ └── users.tsx │ │ │ └── stripe.tsx │ ├── index.tsx │ ├── my-projects.tsx │ ├── projects │ │ ├── [username].tsx │ │ ├── all.tsx │ │ ├── index.tsx │ │ ├── preview │ │ │ └── [projectId].tsx │ │ └── successfulPurchase.tsx │ ├── projectsv2 │ │ └── [projectId] │ │ │ ├── completed.tsx │ │ │ └── index.tsx │ ├── sign-in │ │ └── [[...index]].tsx │ └── sign-up │ │ └── [[...index]].tsx ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── codeBlocks.ts │ │ │ ├── comments.ts │ │ │ ├── instructions.ts │ │ │ ├── projectPreviewEnrollments.ts │ │ │ ├── projects.ts │ │ │ ├── purchases.ts │ │ │ ├── questions.ts │ │ │ └── stripe.ts │ │ └── trpc.ts │ ├── db.ts │ └── helpers │ │ └── ssgHelper.ts ├── styles │ └── globals.css └── utils │ ├── api.ts │ ├── types.ts │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.mjs" 10 | # should be updated accordingly. 11 | 12 | # Prisma 13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 14 | DATABASE_URL="file:./db.sqlite" 15 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | const config = { 6 | overrides: [ 7 | { 8 | extends: [ 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | ], 11 | files: ["*.ts", "*.tsx"], 12 | parserOptions: { 13 | project: path.join(__dirname, "tsconfig.json"), 14 | }, 15 | }, 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: path.join(__dirname, "tsconfig.json"), 20 | }, 21 | plugins: ["@typescript-eslint"], 22 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 23 | rules: { 24 | "@typescript-eslint/consistent-type-imports": [ 25 | "warn", 26 | { 27 | prefer: "type-imports", 28 | fixStyle: "inline-type-imports", 29 | }, 30 | ], 31 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 32 | }, 33 | }; 34 | 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /.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 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | *.react-email/ 44 | **/.idea/** 45 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 15 | 16 | 23 | 24 | 27 | 28 | 35 | 36 | 43 | 44 | 51 | 52 | 57 | 58 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/sweprojects.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SWEProjects 2 | 3 | 4 | # SWE Projects 5 | 6 | SWE Projects is an open source project that aims to help people build projects that go beyond working on localhost. 7 | It aims to create high quality tutorials for high quality projects. 8 | 9 | [sweprojects.com](https://sweprojects.com) 10 | 11 | 12 | ## Why SWE Projects? 13 | SWE Projects was created to be the go-to destination of high quality coding projects and tutorials on how to build them. 14 | 15 | Most existing coding tutorials are random articles found on the internet or multiple hour long videos on Youtube that say a project is "complete" once it works on localhost. 16 | 17 | We want to change that by creating high quality tutorials for technically impressive projects that you can actually deploy out onto the internet and share with friends, families, recruiters. 18 | 19 | 20 | ## Get Started 21 | To run SWE Projects locally, just run the following commands to install the dependencies and run the app locally. 22 | 23 | ``` 24 | npm install 25 | 26 | npm run dev 27 | ``` 28 | 29 | ### Connect your own database 30 | SWE Projects uses Planetscale for our database. To connect your own local instance to SWE Projects, create a Planetsscale 31 | account and create a database. Once you create a database, you can connect it to SWE Projects by creating a `.env` file 32 | at the root directory and add an environment variable called `DATABASE_URL` pointing to your Planetscale db url. 33 | 34 | After adding your `DATABASE_URL`, run `npx prisma db push && npm install` to initialize the database on your local instance. 35 | 36 | You probably also want to set up authentication as well. SWE Projects uses Clerk for authentication. Create your Clerk account 37 | and pass in the `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` env variables to set it all up. 38 | 39 | If you're confused, you should check out Theo's video [here](https://www.youtube.com/watch?v=YkOSUVzOAA4&t=7749s) on how to set up Planetscale and Clerk. 40 | SWE Project uses the t3 starter kit mentioned in the video so you should be able to follow along with the video. 41 | 42 | TODO(YourAverageTechBro): Provide test data script 43 | 44 | We are working on writing up some instructions on how to create your own database instance to develop locally. 45 | 46 | ## Is SWE Projects Free? 47 | 48 | We plan to include the source code for every project that we write a tutorial for. However, we do plan to charge for the step-by-step written tutorials at [sweprojects.com](https://sweprojects.com). 49 | 50 | ## Community & Support 51 | 52 | 53 | * [Discord](https://discord.gg/2p2e5tTmzw) — chat with the SWE Projects team and other developers 54 | * [Canny](https://sweprojects.canny.io/feature-requests) - Request/upvote feature requests and project requests 55 | * [GitHub issues](https://github.com/YourAverageTechBro/SWEProjects/issues/new) - to report bugs 56 | 57 | ## How You Can Contribute 58 | ### Submit a project + Write a Tutorial 59 | 60 | Have a project that you built that you want to write a tutorial for? Join our [Discord](https://discord.gg/2p2e5tTmzw) and 61 | post a message in the `#project-proposal` channel. If you create a project tutorial, we will split all sales of the 62 | project tutorial with you — a great way to make some extra income. 63 | 64 | ### Translate an existing project to another language 65 | We want to make sure that every project in SWE Projects is offered in a variety of languages and tech stacks. 66 | If you see a project that you want to translate into a different project, join our [Discord](https://discord.gg/2p2e5tTmzw) 67 | and post a message in the `#project-translation` channel. 68 | 69 | -------------------------------------------------------------------------------- /assets/SWEProjects-logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/assets/SWEProjects-logo-large.png -------------------------------------------------------------------------------- /assets/SWEProjects-logo-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/assets/SWEProjects-logo-only.png -------------------------------------------------------------------------------- /assets/SWEProjects-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/assets/SWEProjects-logo-small.png -------------------------------------------------------------------------------- /emails/ProjectPurchaseEmail.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@react-email/link"; 2 | import { Section } from "@react-email/section"; 3 | import { Tailwind } from "@react-email/tailwind"; 4 | 5 | type Props = { 6 | projectName: string; 7 | projectUrl: string; 8 | }; 9 | const ProjectPurchaseEmail = ({ projectName, projectUrl }: Props) => { 10 | return ( 11 | 12 |
13 |
14 |
15 | {"SWE 21 |

22 | Congrats on purchasing the {projectName} tutorial! 23 |

24 | 25 |

26 | Click on the button below to access the tutorial 27 |

28 | 29 | 33 | Start The {projectName} Tutorial 34 | 35 | 36 |

37 | If you run into any issues, stop by 38 | 42 | {" the Discord "} 43 | 44 | or send an email to dohyun@youraveragetechbro.com with details 45 | about your issue. We will help you out as soon as we can. 46 |

47 |
48 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | // Styles for the email template 55 | const main = { 56 | backgroundColor: "#ffffff", 57 | fontFamily: 58 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", 59 | }; 60 | 61 | export default ProjectPurchaseEmail; 62 | -------------------------------------------------------------------------------- /emails/WelcomeEmail.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@react-email/link"; 2 | import { Section } from "@react-email/section"; 3 | import { Tailwind } from "@react-email/tailwind"; 4 | 5 | const WelcomeEmail = () => { 6 | return ( 7 | 8 |
9 |
10 |
11 | {"SWE 17 |

Welcome to SWE Projects!

18 | 19 |

20 | Hi, this is Dohyun (also known as YourAverageTechBro on social 21 | media), the founder and creator of SWE Projects. 22 |

23 | 24 |

25 | I wanted to share a few helpful resources regarding SWE Projects. 26 |

27 |
    28 |
  • 29 | Sign up for 30 | 34 | {" email updates "} 35 | 36 | to get notified when new projects and features are released (we 37 | promise to not be annoying) 38 |
  • 39 | 40 |
  • 41 | Join 42 | 46 | {" the Discord "} 47 | 48 | if you are running into any issues/need help 49 |
  • 50 | 51 |
  • 52 | Leave project/feature requests on the 53 | 57 | {" SWE Projects Canny Board"} 58 | 59 |
  • 60 |
61 | 62 |

63 | { 64 | "That's all for now — see you in the Discord and happy learning 🙂" 65 | } 66 |

67 |
68 |
69 |
70 |
71 | ); 72 | }; 73 | 74 | // Styles for the email template 75 | const main = { 76 | backgroundColor: "#ffffff", 77 | fontFamily: 78 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", 79 | }; 80 | 81 | export default WelcomeEmail; 82 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. 3 | * This is especially useful for Docker builds. 4 | */ 5 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs")); 6 | import removeImports from "next-remove-imports"; 7 | 8 | 9 | /** @type {import("next").NextConfig} */ 10 | const config = { 11 | reactStrictMode: true, 12 | 13 | /** 14 | * If you have the "experimental: { appDir: true }" setting enabled, then you 15 | * must comment the below `i18n` config out. 16 | * 17 | * @see https://github.com/vercel/next.js/issues/41980 18 | */ 19 | i18n: { 20 | locales: ["en"], 21 | defaultLocale: "en", 22 | }, 23 | }; 24 | 25 | /** @type {function(import("next").NextConfig): import("next").NextConfig}} */ 26 | const removeImportsFun = removeImports({}) 27 | 28 | export default removeImportsFun({ 29 | webpack: function(config) { 30 | config.module.rules.push({ 31 | test: /\.md$/, 32 | use: 'raw-loader', 33 | }) 34 | return config 35 | }, 36 | }) 37 | // export default config; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sweprojects", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "email": "email dev", 9 | "postinstall": "prisma generate", 10 | "lint": "next lint", 11 | "start": "next start" 12 | }, 13 | "dependencies": { 14 | "@blocknote/core": "^0.5.1", 15 | "@blocknote/react": "^0.5.1", 16 | "@clerk/backend": "^0.17.2", 17 | "@clerk/nextjs": "^4.15.0", 18 | "@headlessui/react": "^1.7.13", 19 | "@heroicons/react": "^2.0.17", 20 | "@mailerlite/mailerlite-nodejs": "^1.0.1", 21 | "@monaco-editor/react": "^4.5.0", 22 | "@react-email/button": "0.0.8", 23 | "@react-email/html": "0.0.4", 24 | "@react-email/link": "^0.0.5", 25 | "@react-email/section": "^0.0.9", 26 | "@react-email/tailwind": "^0.0.8", 27 | "@stripe/stripe-js": "^1.52.1", 28 | "@supabase/auth-helpers-nextjs": "^0.6.1", 29 | "@supabase/auth-helpers-react": "^0.3.1", 30 | "@supabase/auth-ui-react": "^0.3.5", 31 | "@supabase/supabase-js": "^2.19.0", 32 | "@tanstack/react-query": "^4.28.0", 33 | "@trpc/client": "^10.18.0", 34 | "@trpc/next": "^10.18.0", 35 | "@trpc/react-query": "^10.18.0", 36 | "@trpc/server": "^10.18.0", 37 | "@uiw/react-md-editor": "^3.20.8", 38 | "@vercel/analytics": "^0.1.11", 39 | "micro": "^10.0.1", 40 | "mixpanel-browser": "^2.47.0", 41 | "monaco-jsx-syntax-highlight": "^1.2.0", 42 | "next": "^13.3.0", 43 | "next-axiom": "^0.17.0", 44 | "next-remove-imports": "^1.0.11", 45 | "posthog-js": "^1.55.1", 46 | "posthog-node": "^3.1.1", 47 | "react": "18.2.0", 48 | "react-dom": "18.2.0", 49 | "react-email": "^1.9.3", 50 | "react-hot-toast": "^2.4.0", 51 | "react-images-uploading": "^3.1.7", 52 | "resend": "^0.14.0", 53 | "stripe": "^12.2.0", 54 | "superjson": "^1.12.2", 55 | "svix": "^0.84.1", 56 | "zod": "^3.21.4" 57 | }, 58 | "devDependencies": { 59 | "@prisma/client": "^4.14.1", 60 | "@types/eslint": "^8.21.3", 61 | "@types/mixpanel-browser": "^2.38.1", 62 | "@types/node": "^18.15.5", 63 | "@types/prettier": "^2.7.2", 64 | "@types/react": "^18.0.28", 65 | "@types/react-dom": "^18.0.11", 66 | "@typescript-eslint/eslint-plugin": "^5.56.0", 67 | "@typescript-eslint/parser": "^5.56.0", 68 | "autoprefixer": "^10.4.14", 69 | "eslint": "^8.36.0", 70 | "eslint-config-next": "^13.2.4", 71 | "i": "^0.3.7", 72 | "npm": "^9.6.7", 73 | "postcss": "^8.4.21", 74 | "prettier": "^2.8.7", 75 | "prettier-plugin-tailwindcss": "^0.2.6", 76 | "prisma": "^4.14.1", 77 | "tailwindcss": "^3.3.0", 78 | "typescript": "^5.0.2" 79 | }, 80 | "ct3aMetadata": { 81 | "initVersion": "7.10.1" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | relationMode = "prisma" 5 | } 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | model Projects { 12 | id String @id @default(cuid()) 13 | createdAt DateTime @default(now()) 14 | authorId String 15 | projectVariants ProjectVariant[] 16 | thumbnailUrl String @default("") 17 | title String @default("Sample title") @db.LongText 18 | description String @default("An awesome description") @db.LongText 19 | videoDemoUrl String @default("") 20 | stripePriceId String @default("") 21 | purchases Purchases[] 22 | preRequisites String? @default("") @db.LongText 23 | projectAccessType ProjectAccessType? @default(Free) 24 | projectPreviewEnrollments ProjectPreviewEnrollment[] 25 | instructions Instructions[] 26 | 27 | @@index([authorId]) 28 | } 29 | 30 | model Purchases { 31 | id String @id @default(cuid()) 32 | createdAt DateTime @default(now()) 33 | Projects Projects? @relation(fields: [projectsId], references: [id]) 34 | projectsId String? 35 | userId String 36 | } 37 | 38 | model ProjectPreviewEnrollment { 39 | id String @id @default(cuid()) 40 | createdAt DateTime @default(now()) 41 | Projects Projects? @relation(fields: [projectsId], references: [id]) 42 | projectsId String? 43 | userId String 44 | email String 45 | } 46 | 47 | model ProjectVariant { 48 | id String @id @default(cuid()) 49 | createdAt DateTime @default(now()) 50 | frontendVariant FrontendVariant 51 | backendVariant BackendVariant 52 | instructions Instructions[] 53 | Projects Projects? @relation(fields: [projectsId], references: [id]) 54 | projectsId String? 55 | authorId String @default("") 56 | } 57 | 58 | model Instructions { 59 | id String @id @default(cuid()) 60 | createdAt DateTime @default(now()) 61 | explanation String @default("") @db.LongText 62 | ProjectVariant ProjectVariant? @relation(fields: [projectVariantId], references: [id]) 63 | projectVariantId String? 64 | hasCodeBlocks Boolean @default(true) 65 | codeBlock CodeBlocks[] 66 | questions Questions[] 67 | title String @default("") @db.LongText 68 | Projects Projects? @relation(fields: [projectsId], references: [id]) 69 | projectsId String? 70 | } 71 | 72 | model Questions { 73 | id String @id @default(cuid()) 74 | createdAt DateTime @default(now()) 75 | instructionsId String 76 | instructions Instructions? @relation(fields: [instructionsId], references: [id], onDelete: Cascade) 77 | userId String 78 | username String? @default("") @db.VarChar(255) 79 | title String @default("") @db.LongText 80 | question String @default("") @db.LongText 81 | comments Comment[] 82 | } 83 | 84 | model Comment { 85 | id String @id @default(cuid()) 86 | createdAt DateTime @default(now()) 87 | questionId String 88 | questions Questions? @relation(fields: [questionId], references: [id], onDelete: Cascade) 89 | userId String 90 | username String? @default("") @db.VarChar(255) 91 | comment String @default("") @db.LongText 92 | parentCommentId String? 93 | parentComment Comment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: NoAction, onUpdate: NoAction) 94 | replies Comment[] @relation("CommentThread") 95 | } 96 | 97 | model CodeBlocks { 98 | id String @id @default(cuid()) 99 | createdAt DateTime @default(now()) 100 | instructionsId String 101 | instructions Instructions? @relation(fields: [instructionsId], references: [id], onDelete: Cascade) 102 | code String @db.LongText 103 | fileName String 104 | } 105 | 106 | enum FrontendVariant { 107 | NextJS 108 | } 109 | 110 | enum BackendVariant { 111 | Supabase 112 | PlanetScale 113 | } 114 | 115 | enum ProjectAccessType { 116 | Free 117 | Paid 118 | Sponsored 119 | } 120 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/.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 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .idea/ 37 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/README.md: -------------------------------------------------------------------------------- 1 | # FlappyBirdClone 2 | 3 | This is a clone of the popular game Flappy Bird. This game is made using NextJS and Supabase. 4 | 5 | [Here](https://youtu.be/OqO05TfRE3A) is a demo of what the app looks like. 6 | 7 | 8 | ## How To Run Locally 9 | 10 | To run the game locally, run `npm install` to install all the necessary dependencies and then run `npm run dev` to run web appp. 11 | 12 | This iteration of Flappy bird stores the score of the user's game in a database. 13 | This project uses [Supabase](https://supabase.com/) as the database. To set it up, create a `.env` file at the 14 | root directory and include the following env variables. 15 | 16 | ``` 17 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url 18 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_project_anon_public_key 19 | ``` 20 | 21 | You can find the values for the two env variables in your Supabase project settings in the `API` tab. 22 | 23 | Once you add your Supabase keys, you need to create the actual `scores` table in your Supabase project with the following schema: 24 | 25 | 26 | ``` 27 | table scores { 28 | id: string 29 | created_at: string; 30 | score: number; 31 | } 32 | ``` 33 | 34 | ## Want Step-By-Step Instructions? 35 | 36 | If you want a step-by-step tutorial on how to build this project, you can find the tutorial on [here](https://www.sweprojects.com/projects/preview/clgk8x5w1000cvrvb86b13ut7). 37 | 38 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-project", 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" 10 | }, 11 | "dependencies": { 12 | "@supabase/auth-helpers-nextjs": "^0.6.1", 13 | "@supabase/auth-helpers-react": "^0.3.1", 14 | "@supabase/supabase-js": "^2.20.0", 15 | "@types/node": "18.15.11", 16 | "@types/react": "18.0.35", 17 | "@types/react-dom": "18.0.11", 18 | "autoprefixer": "10.4.14", 19 | "eslint": "8.38.0", 20 | "eslint-config-next": "13.3.0", 21 | "next": "13.3.0", 22 | "postcss": "8.4.22", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "tailwindcss": "3.3.1", 26 | "typescript": "5.0.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/pages/api/scores.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createServerSupabaseClient, 3 | SupabaseClient, 4 | } from "@supabase/auth-helpers-nextjs"; 5 | import type { NextApiRequest, NextApiResponse } from "next"; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | try { 12 | const supabase = createServerSupabaseClient({ req, res }); 13 | if (req.method === "POST") { 14 | const { score } = JSON.parse(req.body); 15 | await handlePost(supabase, score, res); 16 | } else if (req.method === "GET") { 17 | await handleGet(supabase, res); 18 | } 19 | } catch (error: any) { 20 | res.status(500).json({ name: error.message }); 21 | } 22 | } 23 | 24 | const handlePost = async ( 25 | supabase: SupabaseClient, 26 | score: number, 27 | res: NextApiResponse 28 | ) => { 29 | const { error } = await supabase.from("scores").insert({ score }); 30 | if (error) throw error; 31 | res.status(200).send("OK"); 32 | }; 33 | 34 | const handleGet = async (supabase: SupabaseClient, res: NextApiResponse) => { 35 | const { data, error } = await supabase 36 | .from("scores") 37 | .select("score") 38 | .order("score", { ascending: false }) 39 | .limit(10); 40 | if (error) throw error; 41 | res.status(200).json({ data: data.map((d) => d.score) }); 42 | }; 43 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/projects/FlappyBirdClone/public/favicon.ico -------------------------------------------------------------------------------- /projects/FlappyBirdClone/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/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 | './app/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /projects/FlappyBirdClone/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 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/.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 | 27 | # local env files 28 | .env 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | *.idea/ 37 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/Components/Account/UsernameEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useUser } from "@supabase/auth-helpers-react"; 3 | 4 | type Props = { 5 | username: string; 6 | }; 7 | export default function UsernameEditor({ username }: Props) { 8 | const [newUsername, setNewUsername] = useState(username); 9 | const user = useUser(); 10 | 11 | const saveUsername = async () => { 12 | try { 13 | const resp = await fetch("/api/users", { 14 | method: "POST", 15 | body: JSON.stringify({ 16 | userId: user?.id, 17 | username: newUsername, 18 | }), 19 | }); 20 | const json = await resp.json(); 21 | if (json.error) { 22 | alert(json.error); 23 | } 24 | } catch (error: any) { 25 | alert(error.message); 26 | } 27 | }; 28 | 29 | return ( 30 |
31 |
32 |

Enter URL and Title

33 | setNewUsername(e.target.value)} 38 | /> 39 | 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/Components/Setup/LinksPreviewComponent.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardIcon, XMarkIcon } from "@heroicons/react/24/solid"; 2 | import { useState } from "react"; 3 | import { Database } from "../../types/supabase"; 4 | import Link from "next/link"; 5 | 6 | type Props = { 7 | username: string; 8 | links: Database["public"]["Tables"]["Links"]["Row"][]; 9 | }; 10 | export default function LinksPreviewComponent({ links, username }: Props) { 11 | const [shouldShowSuccessToast, setShouldShowSuccessToast] = 12 | useState(false); 13 | const showSuccessToast = () => { 14 | setShouldShowSuccessToast(true); 15 | setTimeout(() => { 16 | setShouldShowSuccessToast(false); 17 | }, 3000); 18 | }; 19 | return ( 20 |
21 |
{ 24 | event.preventDefault(); 25 | await navigator.clipboard.writeText( 26 | `${process.env.NEXT_PUBLIC_BASE_URL}/${username}` 27 | ); 28 | showSuccessToast(); 29 | }} 30 | > 31 |

{`${process.env.NEXT_PUBLIC_BASE_URL}/${username}`}

32 | 35 |
setShouldShowSuccessToast(false)} 40 | > 41 |

Link copied!

42 | 43 |
44 |
45 |
49 |
50 | {links.map((link) => ( 51 | 56 | {link.title} 57 | 58 | ))} 59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/Components/Setup/LinksSetupComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useUser } from "@supabase/auth-helpers-react"; 3 | import { Database } from "../../types/supabase"; 4 | import { XMarkIcon } from "@heroicons/react/24/solid"; 5 | import { useRouter } from "next/router"; 6 | 7 | type Props = { 8 | links: Database["public"]["Tables"]["Links"]["Row"][]; 9 | }; 10 | export default function LinksSetupComponent({ links }: Props) { 11 | const [url, setUrl] = useState(""); 12 | const [title, setTitle] = useState(""); 13 | const router = useRouter(); 14 | 15 | const refreshPage = () => router.replace(router.asPath); 16 | 17 | const user = useUser(); 18 | 19 | const addLink = async () => { 20 | try { 21 | if (!user) { 22 | alert("You must be logged in to add a link"); 23 | return; 24 | } 25 | 26 | const resp = await fetch("/api/links", { 27 | method: "POST", 28 | body: JSON.stringify({ 29 | url, 30 | title, 31 | userId: user.id, 32 | }), 33 | }); 34 | const json = await resp.json(); 35 | if (json.error) { 36 | alert(json.error); 37 | } else { 38 | resetState(); 39 | void refreshPage(); 40 | } 41 | } catch (error: any) { 42 | alert(error.message); 43 | } 44 | }; 45 | 46 | const deleteLink = async (linkId: number) => { 47 | try { 48 | if (!user) { 49 | alert("You must be logged in to delete a link"); 50 | return; 51 | } 52 | 53 | const resp = await fetch( 54 | `/api/links?userId=${user.id}&linkId=${linkId}`, 55 | { 56 | method: "DELETE", 57 | } 58 | ); 59 | const json = await resp.json(); 60 | if (json.error) { 61 | alert(json.error); 62 | } else { 63 | void refreshPage(); 64 | resetState(); 65 | } 66 | } catch (error: any) { 67 | alert(error.message); 68 | } 69 | }; 70 | 71 | const resetState = () => { 72 | setUrl(""); 73 | setTitle(""); 74 | }; 75 | 76 | return ( 77 |
78 |
79 |

Enter URL and Title

80 | setTitle(e.target.value)} 85 | /> 86 | setUrl(e.target.value)} 91 | /> 92 | 98 |
99 | {links.map((link) => ( 100 |
106 |
107 |

{link.title}

108 |

{link.url}

109 |
110 | 113 |
114 | ))} 115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/Components/common/BackgroundGradient.tsx: -------------------------------------------------------------------------------- 1 | export default function BackgroundGradient() { 2 | return
3 |
4 | 10 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | } -------------------------------------------------------------------------------- /projects/LinkTreeClone/Components/common/Header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { createBrowserSupabaseClient } from "@supabase/auth-helpers-nextjs"; 3 | import { useRouter } from "next/router"; 4 | import { useState } from "react"; 5 | 6 | export default function Header() { 7 | const [supabase] = useState(() => createBrowserSupabaseClient()); 8 | const router = useRouter(); 9 | 10 | const handleSignOut = async () => { 11 | const { error } = await supabase.auth.signOut(); 12 | if (error) { 13 | alert(error.message); 14 | } else { 15 | await router.push("/"); 16 | } 17 | }; 18 | 19 | const navigateToAccountPage = async () => { 20 | await router.push("/account"); 21 | }; 22 | 23 | return ( 24 |
29 | 30 |

LinkTree Clone

31 | 32 |
33 | 40 | 41 | 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/README.md: -------------------------------------------------------------------------------- 1 | # LinkTree Clone 2 | 3 | This is a clone of the popular website Linktree, a link aggregator website that allows users to create a page with links 4 | to their social media profiles. [Here](https://www.beacons.ai/youraveragetechbro) is an example of one. 5 | 6 | [Here](https://youtu.be/rVRaV-qctm4) is a demo of what the app looks like. 7 | 8 | 9 | ## How To Run Locally 10 | 11 | To run the game locally, run `npm install` to install all the necessary dependencies and then run `npm run dev` to run web appp. 12 | 13 | This project uses [Supabase](https://supabase.com/) as the database. To set it up, create a `.env` file at the 14 | root directory and include the following env variables. 15 | 16 | ``` 17 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url 18 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_project_anon_public_key 19 | ``` 20 | 21 | You can find the values for the two env variables in your Supabase project settings in the `API` tab. 22 | 23 | Once you add your Supabase keys, you need to create the necessary tables in Supabase. 24 | 25 | You find the database schema in the [types/supabase.ts](https://www.sweprojects.com/projects/preview/clh7qgfw30000vr1re1505imehttps://github.com/YourAverageTechBro/SWEProjects/blob/main/projects/LinkTreeClone/types/supabase.ts) file. 26 | 27 | ## Want Step-By-Step Instructions? 28 | 29 | If you want a step-by-step tutorial on how to build this project, you can find the tutorial on [here](https://www.sweprojects.com/projects/preview/clh7qgfw30000vr1re1505ime). 30 | 31 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-project", 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" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.0.17", 13 | "@supabase/auth-helpers-nextjs": "^0.6.0", 14 | "@supabase/auth-helpers-react": "^0.3.1", 15 | "@supabase/auth-ui-react": "^0.3.5", 16 | "@supabase/auth-ui-shared": "^0.1.3", 17 | "@supabase/supabase-js": "^2.21.0", 18 | "@types/node": "18.15.11", 19 | "@types/react": "18.0.35", 20 | "@types/react-dom": "18.0.11", 21 | "autoprefixer": "10.4.14", 22 | "eslint": "8.38.0", 23 | "eslint-config-next": "13.3.0", 24 | "next": "13.3.0", 25 | "postcss": "8.4.22", 26 | "react": "18.2.0", 27 | "react-dom": "18.2.0", 28 | "tailwindcss": "3.3.1", 29 | "typescript": "5.0.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/[username].tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from "next"; 2 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 3 | import { Database } from "../types/supabase"; 4 | import Link from "next/link"; 5 | 6 | type Props = { 7 | links: Database["public"]["Tables"]["Links"]["Row"][]; 8 | username: string; 9 | }; 10 | export default function CreatorPage({ links, username }: Props) { 11 | return ( 12 |
13 |

Links for {username}

14 |
15 | {links.map((link) => ( 16 | 21 | {link.title} 22 | 23 | ))} 24 |
25 |
26 | ); 27 | } 28 | 29 | export const getServerSideProps: GetServerSideProps = async (context) => { 30 | const supabase = createServerSupabaseClient(context); 31 | const { username } = context.params as { username: string }; 32 | const { 33 | data: { session }, 34 | } = await supabase.auth.getSession(); 35 | 36 | if (!session || !username) 37 | return { 38 | redirect: { 39 | destination: "/", 40 | permanent: false, 41 | }, 42 | }; 43 | const { data: user, error: fetchUsernameError } = await supabase 44 | .from("Users") 45 | .select("*") 46 | .eq("username", username) 47 | .single(); 48 | 49 | if (fetchUsernameError) { 50 | return { 51 | redirect: { 52 | destination: "/", 53 | permanent: false, 54 | }, 55 | }; 56 | } 57 | 58 | const { data: links, error: fetchLinksError } = await supabase 59 | .from("Links") 60 | .select("*") 61 | .eq("user_id", user.id); 62 | 63 | if (fetchLinksError) { 64 | return { 65 | redirect: { 66 | destination: "/", 67 | permanent: false, 68 | }, 69 | }; 70 | } 71 | 72 | return { 73 | props: { 74 | links, 75 | username, 76 | }, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { useState } from "react"; 4 | import { SessionContextProvider } from "@supabase/auth-helpers-react"; 5 | import { 6 | createBrowserSupabaseClient, 7 | Session, 8 | } from "@supabase/auth-helpers-nextjs"; 9 | import { Database } from "../types/supabase"; 10 | 11 | export default function App({ 12 | Component, 13 | pageProps, 14 | }: AppProps<{ initialSession: Session }>) { 15 | const [supabaseClient] = useState(() => 16 | createBrowserSupabaseClient() 17 | ); 18 | return ( 19 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/account.tsx: -------------------------------------------------------------------------------- 1 | import Header from "../Components/common/Header"; 2 | import UsernameEditor from "../Components/Account/UsernameEditor"; 3 | import { GetServerSideProps } from "next"; 4 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 5 | 6 | type Props = { 7 | username: string; 8 | }; 9 | export default function Account({ username }: Props) { 10 | return ( 11 |
12 |
13 | 14 |
15 | ); 16 | } 17 | export const getServerSideProps: GetServerSideProps = async (context) => { 18 | const supabase = createServerSupabaseClient(context); 19 | const { 20 | data: { session }, 21 | } = await supabase.auth.getSession(); 22 | 23 | const userId = session?.user.id; 24 | if (!userId) { 25 | return { 26 | redirect: { 27 | destination: "/", 28 | permanent: false, 29 | }, 30 | }; 31 | } 32 | const { data: user, error: fetchUsernameError } = await supabase 33 | .from("Users") 34 | .select("*") 35 | .eq("id", userId) 36 | .single(); 37 | 38 | if (fetchUsernameError) { 39 | return { 40 | redirect: { 41 | destination: "/", 42 | permanent: false, 43 | }, 44 | }; 45 | } 46 | 47 | return { 48 | props: { 49 | username: user.username, 50 | }, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/api/links.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { Database } from "../../types/supabase"; 3 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 4 | 5 | type Data = { 6 | error?: string; 7 | links?: Database["public"]["Tables"]["Links"]["Row"][]; 8 | }; 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | try { 14 | if (req.method === "POST") { 15 | await handlePost(req, res); 16 | } else if (req.method === "GET") { 17 | await handleGet(req, res); 18 | } else if (req.method === "DELETE") { 19 | await handleDelete(req, res); 20 | } 21 | } catch (error: any) { 22 | res.status(500).json({ error: error.message }); 23 | } 24 | } 25 | 26 | const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { 27 | const supabaseServerClient = createServerSupabaseClient({ 28 | req, 29 | res, 30 | }); 31 | const body = JSON.parse(req.body); 32 | const { url, title, userId } = body as { 33 | url: string; 34 | title: string; 35 | userId: string; 36 | }; 37 | 38 | if (!url || !title || !userId) { 39 | res.status(400).json({ error: "Missing url, title, or userId" }); 40 | return; 41 | } 42 | 43 | const { error: insertError } = await supabaseServerClient 44 | .from("Links") 45 | .insert({ 46 | url, 47 | title, 48 | user_id: userId, 49 | }); 50 | 51 | if (insertError) { 52 | res.status(500).json({ error: insertError.message }); 53 | } else { 54 | res.status(200).json({}); 55 | } 56 | }; 57 | 58 | const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { 59 | const supabaseServerClient = createServerSupabaseClient({ 60 | req, 61 | res, 62 | }); 63 | const { userId } = req.query; 64 | 65 | if (!userId) { 66 | res.status(400).json({ error: "Missing userId" }); 67 | return; 68 | } 69 | 70 | const { data, error } = await supabaseServerClient 71 | .from("Links") 72 | .select("*") 73 | .eq("user_id", userId); 74 | if (error) { 75 | res.status(500).json({ error: error.message }); 76 | } else { 77 | res.status(200).json({ links: data }); 78 | } 79 | }; 80 | 81 | const handleDelete = async ( 82 | req: NextApiRequest, 83 | res: NextApiResponse 84 | ) => { 85 | const supabaseServerClient = createServerSupabaseClient({ 86 | req, 87 | res, 88 | }); 89 | const { linkId, userId } = req.query; 90 | 91 | if (!userId || !linkId) { 92 | res.status(400).json({ error: "Invalid parameters" }); 93 | return; 94 | } 95 | 96 | const { error: deleteError } = await supabaseServerClient 97 | .from("Links") 98 | .delete() 99 | .eq("user_id", userId) 100 | .eq("id", linkId); 101 | if (deleteError) { 102 | res.status(500).json({ error: deleteError.message }); 103 | } else { 104 | res.status(200).json({}); 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/api/usernames.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { Database } from "../../types/supabase"; 3 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 4 | 5 | type Data = { 6 | error?: string; 7 | usernames?: Database["public"]["Tables"]["Users"]["Row"]["username"][]; 8 | }; 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | try { 14 | if (req.method === "GET") { 15 | await handleGet(req, res); 16 | } 17 | } catch (error: any) { 18 | res.status(500).json({ error: error.message }); 19 | } 20 | } 21 | const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { 22 | const supabaseServerClient = createServerSupabaseClient({ 23 | req, 24 | res, 25 | }); 26 | 27 | const { data, error } = await supabaseServerClient 28 | .from("Users") 29 | .select("username"); 30 | if (error) { 31 | res.status(500).json({ error: error.message }); 32 | } else { 33 | res.status(200).json({ usernames: data.map((data) => data.username) }); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/api/users.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { Database } from "../../types/supabase"; 3 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 4 | 5 | type Data = { 6 | error?: string; 7 | user?: Database["public"]["Tables"]["Users"]["Row"]; 8 | }; 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | try { 14 | if (req.method === "POST") { 15 | await handlePost(req, res); 16 | } 17 | } catch (error: any) { 18 | res.status(500).json({ error: error.message }); 19 | } 20 | } 21 | const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { 22 | const supabaseServerClient = createServerSupabaseClient({ 23 | req, 24 | res, 25 | }); 26 | const body = JSON.parse(req.body); 27 | const { username, userId } = body as { 28 | username: string; 29 | userId: string; 30 | }; 31 | 32 | if (!username || !userId) { 33 | res.status(400).json({ error: "Missing username or userId" }); 34 | return; 35 | } 36 | 37 | const { error: updateError } = await supabaseServerClient 38 | .from("Users") 39 | .update({ 40 | username, 41 | }) 42 | .eq("id", userId); 43 | 44 | if (updateError) { 45 | res.status(500).json({ error: updateError.message }); 46 | } else { 47 | res.status(200).json({}); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/auth.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from "@supabase/auth-ui-react"; 2 | import { ThemeSupa } from "@supabase/auth-ui-shared"; 3 | import { useEffect, useState } from "react"; 4 | import { createBrowserSupabaseClient } from "@supabase/auth-helpers-nextjs"; 5 | import { useRouter } from "next/router"; 6 | 7 | export default function AuthPage() { 8 | const [supabaseClient] = useState(() => createBrowserSupabaseClient()); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | const { 13 | data: { subscription }, 14 | } = supabaseClient.auth.onAuthStateChange(async (event, session) => { 15 | const userEmail = session?.user.email; 16 | const userId = session?.user.id; 17 | if (event === "SIGNED_IN" && userId && userEmail) { 18 | await router.push(`/setup`); 19 | } 20 | }); 21 | return () => { 22 | subscription?.unsubscribe(); 23 | }; 24 | }, [router, supabaseClient.auth]); 25 | 26 | return ( 27 |
28 |

29 |
30 |

Welcome to LinkTree Clone.

31 | 36 |
37 |

38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import BackgroundGradient from "../Components/common/BackgroundGradient"; 2 | import Link from "next/link"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 | 8 |
9 |

10 | Create your link in bio 11 |

12 | 16 | Get Started 17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/pages/setup.tsx: -------------------------------------------------------------------------------- 1 | import { Database } from "../types/supabase"; 2 | import LinksPreviewComponent from "../Components/Setup/LinksPreviewComponent"; 3 | import LinksSetupComponent from "../Components/Setup/LinksSetupComponent"; 4 | import Header from "../Components/common/Header"; 5 | import { GetServerSideProps } from "next"; 6 | import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs"; 7 | 8 | type Props = { 9 | links: Database["public"]["Tables"]["Links"]["Row"][]; 10 | username: string; 11 | }; 12 | export default function Setup({ links, username }: Props) { 13 | return ( 14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 | ); 22 | } 23 | export const getServerSideProps: GetServerSideProps = async (context) => { 24 | const supabase = createServerSupabaseClient(context); 25 | const { 26 | data: { session }, 27 | } = await supabase.auth.getSession(); 28 | 29 | const userId = session?.user.id; 30 | if (!userId) { 31 | return { 32 | redirect: { 33 | destination: "/", 34 | permanent: false, 35 | }, 36 | }; 37 | } 38 | 39 | const { data: user, error: fetchUsernameError } = await supabase 40 | .from("Users") 41 | .select("*") 42 | .eq("id", userId) 43 | .single(); 44 | 45 | if (fetchUsernameError) { 46 | return { 47 | redirect: { 48 | destination: "/", 49 | permanent: false, 50 | }, 51 | }; 52 | } 53 | 54 | const { data: links, error: fetchLinksError } = await supabase 55 | .from("Links") 56 | .select("*") 57 | .eq("user_id", userId); 58 | 59 | if (fetchLinksError) { 60 | return { 61 | redirect: { 62 | destination: "/", 63 | permanent: false, 64 | }, 65 | }; 66 | } 67 | 68 | return { 69 | props: { 70 | links, 71 | username: user.username, 72 | }, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/projects/LinkTreeClone/public/favicon.ico -------------------------------------------------------------------------------- /projects/LinkTreeClone/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: light) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/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 | './app/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/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 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /projects/LinkTreeClone/types/supabase.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json } 7 | | Json[] 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | Links: { 13 | Row: { 14 | created_at: string | null 15 | id: number 16 | title: string 17 | url: string 18 | user_id: string 19 | } 20 | Insert: { 21 | created_at?: string | null 22 | id?: number 23 | title: string 24 | url: string 25 | user_id: string 26 | } 27 | Update: { 28 | created_at?: string | null 29 | id?: number 30 | title?: string 31 | url?: string 32 | user_id?: string 33 | } 34 | } 35 | Users: { 36 | Row: { 37 | created_at: string | null 38 | id: string 39 | username: string 40 | } 41 | Insert: { 42 | created_at?: string | null 43 | id: string 44 | username: string 45 | } 46 | Update: { 47 | created_at?: string | null 48 | id?: string 49 | username?: string 50 | } 51 | } 52 | } 53 | Views: { 54 | [_ in never]: never 55 | } 56 | Functions: { 57 | [_ in never]: never 58 | } 59 | Enums: { 60 | [_ in never]: never 61 | } 62 | CompositeTypes: { 63 | [_ in never]: never 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YourAverageTechBro/SWEProjects/4e820f4a98ecfa6fc8c2e7c6fa397ebcc796e209/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Common/BackgroundGradient.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function BackgroundGradient() { 4 | return ( 5 |
6 | 12 | 17 | 18 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | } -------------------------------------------------------------------------------- /src/components/Common/Header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SignedIn, 3 | SignedOut, 4 | SignInButton, 5 | UserButton, 6 | useUser, 7 | } from "@clerk/nextjs"; 8 | import { Button } from "src/components/LandingPage/Button"; 9 | import Link from "next/link"; 10 | import { Logo } from "~/components/LandingPage/Logo"; 11 | import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react"; 12 | 13 | export default function Header() { 14 | const { user } = useUser(); 15 | const postHog = usePostHog(); 16 | const isNewProjectCreationFlowEnabled = useFeatureFlagEnabled( 17 | "new-project-creation-flow" 18 | ); 19 | 20 | return ( 21 |
29 | 30 | { 33 | postHog?.identify(user?.id, { 34 | name: user?.fullName, 35 | email: user?.primaryEmailAddress?.emailAddress, 36 | }); 37 | postHog?.capture("Click on Logo", { 38 | distinct_id: user?.id, 39 | time: new Date(), 40 | }); 41 | }} 42 | /> 43 | 44 |
45 | 46 | {isNewProjectCreationFlowEnabled && ( 47 | 53 | {" "} 54 | My Project Tutorials 55 | 56 | )} 57 | { 62 | postHog?.identify(user?.id, { 63 | name: user?.fullName, 64 | email: user?.primaryEmailAddress?.emailAddress, 65 | }); 66 | postHog?.capture("Click on my projects", { 67 | distinct_id: user?.id, 68 | time: new Date(), 69 | }); 70 | }} 71 | > 72 | {" "} 73 | Purchased projects{" "} 74 | 75 | { 80 | postHog?.identify(user?.id, { 81 | name: user?.fullName, 82 | email: user?.primaryEmailAddress?.emailAddress, 83 | }); 84 | postHog?.capture("Click on view all projects", { 85 | distinct_id: user?.id, 86 | time: new Date(), 87 | }); 88 | }} 89 | > 90 | View all projects 91 | 92 | 93 | 94 | 95 | {/* Signed-out users get sign in button */} 96 | 97 | {/*eslint-disable-next-line @typescript-eslint/ban-ts-comment*/} 98 | {/*@ts-ignore*/} 99 | 102 | 103 | 104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/components/Common/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | styleOverride?: string; 3 | spinnerColor?: string; 4 | }; 5 | export default function LoadingSpinner({ 6 | styleOverride, 7 | spinnerColor = "fill-blue-600 text-gray-200 dark:text-gray-600", 8 | }: Props) { 9 | return ( 10 |
14 | 30 | Loading... 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Common/MdEditor/MdEditor.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction } from "react"; 2 | import { onImagePasted } from "./utils"; 3 | import "@uiw/react-md-editor/markdown-editor.css"; 4 | import "@uiw/react-markdown-preview/markdown.css"; 5 | import { createClient } from "@supabase/supabase-js"; 6 | import dynamic from "next/dynamic"; 7 | 8 | const MDEditor = dynamic( 9 | () => import("@uiw/react-md-editor").then((mod) => mod.default), 10 | { ssr: false } 11 | ); 12 | 13 | type Props = { 14 | value: string | undefined; 15 | onChange: Dispatch>; 16 | index: number; 17 | hideToolbar: boolean; 18 | preview: "edit" | "preview" | "live"; 19 | height: string; 20 | }; 21 | const MdEditor = ({ 22 | value, 23 | onChange, 24 | index, 25 | preview, 26 | hideToolbar, 27 | height, 28 | }: Props) => { 29 | const supabase = createClient( 30 | process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", 31 | process.env.NEXT_PUBLIC_SUPABASE_SERVICE_SECRET_KEY ?? "" 32 | ); 33 | 34 | // eslint-disable @typescript-eslint/ban-ts-comment 35 | return ( 36 | { 41 | onChange(value); 42 | }} 43 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ 44 | onPaste={async (event) => { 45 | await onImagePasted(event.clipboardData, onChange, supabase, index); 46 | }} 47 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ 48 | onDrop={async (event) => { 49 | await onImagePasted(event.dataTransfer, onChange, supabase, index); 50 | }} 51 | /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ 52 | /* @ts-ignore */ 53 | height={height} 54 | /> 55 | ); 56 | }; 57 | 58 | export default MdEditor; 59 | -------------------------------------------------------------------------------- /src/components/Common/MdEditor/utils.tsx: -------------------------------------------------------------------------------- 1 | import type { SetStateAction } from "react"; 2 | import { type SupabaseClient } from "@supabase/supabase-js"; 3 | 4 | export const insertTextToArea = ( 5 | insertString: string, 6 | textAreaIndex: number 7 | ) => { 8 | const textareas = document.querySelectorAll( 9 | "textarea.w-md-editor-text-input" 10 | ); 11 | if (textareas.length === 0) { 12 | return null; 13 | } 14 | const textarea = textareas[textAreaIndex] as HTMLTextAreaElement; 15 | if (!textarea) return null; 16 | 17 | let sentence = textarea.value; 18 | const len = sentence.length; 19 | const pos = textarea.selectionStart; 20 | const end = textarea.selectionEnd; 21 | 22 | const front = sentence.slice(0, pos); 23 | const back = sentence.slice(pos, len); 24 | 25 | sentence = front + insertString + back; 26 | 27 | textarea.value = sentence; 28 | textarea.selectionEnd = end + insertString.length; 29 | 30 | return sentence; 31 | }; 32 | 33 | export const onImagePasted = async ( 34 | dataTransfer: DataTransfer, 35 | setMarkdown: (value: SetStateAction) => void, 36 | supabase: SupabaseClient, 37 | textAreaIndex: number 38 | ) => { 39 | const files: File[] = []; 40 | for (let index = 0; index < dataTransfer.items.length; index += 1) { 41 | const file = dataTransfer.files.item(index); 42 | 43 | if (file) { 44 | files.push(file); 45 | } 46 | } 47 | 48 | await Promise.all( 49 | files.map(async (file) => { 50 | const url = await fileUpload(file, supabase); 51 | const insertedMarkdown = insertTextToArea(`![](${url})`, textAreaIndex); 52 | if (!insertedMarkdown) { 53 | return; 54 | } 55 | setMarkdown(insertedMarkdown); 56 | }) 57 | ); 58 | }; 59 | 60 | export const fileUpload = async (file: File, supabase: SupabaseClient) => { 61 | const { data, error } = await supabase.storage 62 | .from("public/images") 63 | .upload(`public/images${file.name}`, file, { 64 | cacheControl: "3600", 65 | upsert: true, 66 | }); 67 | 68 | if (error) throw error; 69 | const resp = supabase.storage.from("public/images").getPublicUrl(data.path); 70 | return resp.data.publicUrl; 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/Common/PhotoUploadComponent.tsx: -------------------------------------------------------------------------------- 1 | import ImageUploading, { type ImageListType } from "react-images-uploading"; 2 | import { XCircleIcon } from "@heroicons/react/24/solid"; 3 | 4 | type Props = { 5 | onSaveCallback: () => Promise; 6 | images: ImageListType; 7 | setImages: (images: ImageListType) => void; 8 | caption: string; 9 | setCaption: (caption: string) => void; 10 | }; 11 | export default function PhotoUploadComponent({ 12 | onSaveCallback, 13 | images, 14 | setImages, 15 | caption, 16 | setCaption, 17 | }: Props) { 18 | const onContentImageChange = (imageList: ImageListType) => { 19 | setImages(imageList); 20 | }; 21 | 22 | const saveImages = async () => { 23 | await onSaveCallback(); 24 | }; 25 | 26 | return ( 27 |
28 | 34 | {({ 35 | imageList, 36 | onImageUpload, 37 | onImageRemove, 38 | isDragging, 39 | dragProps, 40 | }) => ( 41 |
42 | {images.length === 0 && ( 43 |
49 | Click or Drop An Image Here 50 |
51 | )} 52 |   53 | {imageList.map((image, index) => ( 54 |
55 | space-image 61 |
62 | onImageRemove(index)} 65 | /> 66 |
67 | setCaption(e.target.value)} 73 | /> 74 |
75 | ))} 76 |
77 | )} 78 |
79 |
80 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/Common/Selectors.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, Fragment, type SetStateAction, useEffect } from "react"; 2 | import { Listbox, Transition } from "@headlessui/react"; 3 | import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid"; 4 | 5 | function classNames(...classes: string[]) { 6 | return classes.filter(Boolean).join(" "); 7 | } 8 | 9 | type Props = { 10 | values: string[]; 11 | selected: string; 12 | setSelected: Dispatch>; 13 | label: string; 14 | }; 15 | 16 | export default function Selectors({ 17 | selected, 18 | setSelected, 19 | values, 20 | label, 21 | }: Props) { 22 | useEffect(() => { 23 | if (!selected && values[0]) { 24 | setSelected(values[0]); 25 | } 26 | }, [selected, setSelected, values]); 27 | return ( 28 | 29 | {({ open }) => ( 30 | <> 31 | 32 | {label} 33 | 34 |
35 | 36 | {selected} 37 | 38 | 43 | 44 | 45 | 52 | 53 | {values.map((value, index) => ( 54 | 57 | classNames( 58 | active ? "bg-indigo-600 text-white" : "text-gray-900", 59 | "relative cursor-default select-none py-2 pl-3 pr-9" 60 | ) 61 | } 62 | value={value} 63 | > 64 | {({ selected, active }) => ( 65 | <> 66 | 72 | {value} 73 | 74 | 75 | {selected ? ( 76 | 82 | 84 | ) : null} 85 | 86 | )} 87 | 88 | ))} 89 | 90 | 91 |
92 | 93 | )} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/DraftProjects/DraftInstructionalTextComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { api } from "~/utils/api"; 3 | import { toast } from "react-hot-toast"; 4 | import { debounce } from "throttle-debounce"; 5 | import "@uiw/react-md-editor/markdown-editor.css"; 6 | import "@uiw/react-markdown-preview/markdown.css"; 7 | import MdEditor from "~/components/Common/MdEditor/MdEditor"; 8 | 9 | type Props = { 10 | initialValue?: string; 11 | instructionId: string; 12 | index: number; 13 | readOnly: boolean; 14 | isAuthor: boolean; 15 | }; 16 | export default function DraftInstructionalTextComponent({ 17 | initialValue, 18 | instructionId, 19 | index, 20 | readOnly, 21 | isAuthor, 22 | }: Props) { 23 | const [explanation, setExplanation] = useState( 24 | initialValue 25 | ); 26 | const [isInitialized, setIsInitialized] = useState(false); 27 | 28 | const debouncedSave = useCallback( 29 | debounce(1000, (instructionId: string, explanation: string) => { 30 | mutate({ instructionId, explanation }); 31 | }), 32 | [] 33 | ); 34 | 35 | useEffect(() => { 36 | try { 37 | if (explanation && isAuthor) { 38 | debouncedSave(instructionId, explanation); 39 | } 40 | } catch (error: any) {} 41 | }, [explanation, debouncedSave]); 42 | 43 | const { mutate, isLoading: isSaving } = api.instructions.update.useMutation({ 44 | onSuccess: (project) => { 45 | if (!isInitialized) { 46 | setIsInitialized(true); 47 | } else { 48 | // Show successfully saved state 49 | toast.success("Saved!"); 50 | } 51 | }, 52 | onError: (e) => { 53 | const errorMessage = e.data?.zodError?.fieldErrors.content; 54 | if (errorMessage && errorMessage[0]) { 55 | toast.error(errorMessage[0]); 56 | } else { 57 | toast.error("Failed to save! Please try again later."); 58 | } 59 | }, 60 | }); 61 | 62 | return ( 63 |
67 | 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/DraftProjects/Editor.tsx: -------------------------------------------------------------------------------- 1 | // import logo from './logo.svg' 2 | import "@blocknote/core/style.css"; 3 | import { BlockNoteView, useBlockNote } from "@blocknote/react"; 4 | import { type Block, type BlockNoteEditor } from "@blocknote/core"; 5 | import { type Dispatch, type SetStateAction } from "react"; 6 | 7 | type Props = { 8 | initialValue: Block[] | undefined; 9 | onChange: Dispatch>; 10 | }; 11 | function Editor({ initialValue, onChange }: Props) { 12 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment 13 | const editor: BlockNoteEditor | null = useBlockNote({ 14 | onEditorContentChange: (editor: BlockNoteEditor) => { 15 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access 16 | onChange(editor.topLevelBlocks); 17 | }, 18 | initialContent: initialValue ?? undefined, 19 | }); 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 22 | return ; 23 | } 24 | 25 | export default Editor; 26 | -------------------------------------------------------------------------------- /src/components/DraftProjects/FocusedQuestion.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowSmallLeftIcon } from "@heroicons/react/24/solid"; 2 | import { type Dispatch, type SetStateAction } from "react"; 3 | import { type Questions } from "@prisma/client"; 4 | import CommentBox from "~/components/QuestionsAndAnswers/CommentBox"; 5 | import CommentsList from "~/components/QuestionsAndAnswers/CommentsList"; 6 | 7 | type Props = { 8 | question: Questions; 9 | setFocusedQuestion: Dispatch>; 10 | }; 11 | export default function FocusedQuestionComponent({ 12 | question, 13 | setFocusedQuestion, 14 | }: Props) { 15 | return ( 16 |
17 | 20 |
{question.title}
21 |
{question.question}
22 | 23 |
24 | 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/DraftProjects/InstructionLeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import DraftInstructionalTextComponent from "~/components/DraftProjects/DraftInstructionalTextComponent"; 3 | import QuestionsPlaceholder from "~/components/Images/Questions"; 4 | 5 | enum SideBarContent { 6 | INSTRUCTIONS = "Instructions", 7 | QUESTIONS = "Questions", 8 | } 9 | 10 | type Props = { 11 | explanation: string; 12 | instructionId: string; 13 | index: number; 14 | isEditing: boolean; 15 | isAuthor: boolean; 16 | isQAFeatureEnabled: boolean; 17 | }; 18 | export default function InstructionLeftSidebar({ 19 | explanation, 20 | instructionId, 21 | index, 22 | isEditing, 23 | isAuthor, 24 | isQAFeatureEnabled, 25 | }: Props) { 26 | const [focusedSideBarContent, setFocusedSideBarContent] = useState( 27 | SideBarContent.INSTRUCTIONS 28 | ); 29 | return ( 30 |
31 |
32 | {Object.values(SideBarContent).map((sideBarContent, index) => ( 33 |
39 | setFocusedSideBarContent(sideBarContent as SideBarContent) 40 | } 41 | > 42 | {sideBarContent} 43 |
44 | ))} 45 |
46 |
47 | {focusedSideBarContent === SideBarContent.INSTRUCTIONS && ( 48 | 55 | )} 56 | {focusedSideBarContent === SideBarContent.QUESTIONS && 57 | !isQAFeatureEnabled && ( 58 |
63 | {" "} 64 | {" "} 65 |

66 | {" "} 67 | We are launching the questions feature soon!{" "} 68 |

69 |

70 | Hold onto your questions for now and we will let you know when 71 | you can add them here! 72 |

73 |
74 | )} 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/LandingPage/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import backgroundImage from "./images/background-auth.jpg"; 4 | import React from "react"; 5 | 6 | type Props = { 7 | children: React.ReactNode; 8 | }; 9 | export function AuthLayout({ children }: Props) { 10 | return ( 11 | <> 12 |
13 |
14 |
15 | {children} 16 |
17 |
18 |
19 | 25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/LandingPage/Button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import clsx from "clsx"; 3 | 4 | const baseStyles = { 5 | solid: 6 | "group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2", 7 | outline: 8 | "group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none", 9 | }; 10 | 11 | const variantStyles = { 12 | solid: { 13 | slate: 14 | "bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900", 15 | blue: "bg-blue-600 text-white hover:text-slate-100 hover:bg-blue-500 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600", 16 | white: 17 | "bg-white text-slate-900 hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", 18 | }, 19 | outline: { 20 | slate: 21 | "ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-blue-600 focus-visible:ring-slate-300", 22 | white: 23 | "ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white", 24 | }, 25 | }; 26 | 27 | type Props = { 28 | variant?: "solid" | "outline"; 29 | color?: "slate" | "blue" | "white"; 30 | className?: string; 31 | href?: string; 32 | }; 33 | 34 | export function Button({ 35 | variant = "solid", 36 | color = "slate", 37 | className, 38 | href, 39 | ...props 40 | }: Props) { 41 | className = clsx( 42 | baseStyles[variant], 43 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 44 | // @ts-ignore 45 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 46 | variantStyles[variant][color], 47 | className 48 | ); 49 | 50 | return href ? ( 51 | 52 | ) : ( 53 | 40 | ) : ( 41 | 42 | {/*eslint-disable-next-line @typescript-eslint/ban-ts-comment*/} 43 | {/*@ts-ignore*/} 44 | 49 | 50 | )} 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/LandingPage/Container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore 5 | export function Container({ className, ...props }) { 6 | return ( 7 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/LandingPage/Faqs.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { Container } from "src/components/LandingPage/Container"; 4 | import backgroundImage from "./images/background-faqs.jpg"; 5 | 6 | const faqs = [ 7 | { 8 | question: "How long do I have access to a project once I purchase it?", 9 | answer: ( 10 |

11 | {`You get lifetime access to the project. That means whenever we add a 12 | new tech stack variation to the project, you get lifetime access to 13 | that, too.`} 14 |

15 | ), 16 | }, 17 | { 18 | question: "Do you offer any discounts?", 19 | answer: ( 20 |

21 | {`Currently no, but we are looking into some ways to offer students 22 | discounts. If you sign up for an account, you'll be the first to know 23 | about any discounts.`} 24 |

25 | ), 26 | }, 27 | { 28 | question: "Can I request a type of project to be made?", 29 | answer: ( 30 |

31 | Yep, you definitely can. You can do so 32 | 36 | {" "} 37 | here{" "} 38 | 39 |

40 | ), 41 | }, 42 | ]; 43 | 44 | export function Faqs() { 45 | return ( 46 |
51 | 59 | 60 |
61 |

65 | Frequently asked questions 66 |

67 |

68 | If you can’t find what you’re looking for, email our support team 69 | and if you’re lucky someone will get back to you. 70 |

71 |
72 |
73 | {faqs.map((column, columnIndex) => ( 74 |
75 |

76 | {column.question} 77 |

78 | {column.answer} 79 |
80 | ))} 81 |
82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/LandingPage/Fields.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | const formClasses = 4 | "block w-full appearance-none rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-blue-500 sm:text-sm"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | function Label({ id, children }) { 9 | return ( 10 | 17 | ); 18 | } 19 | 20 | export function TextField({ 21 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 22 | // @ts-ignore 23 | id, 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 25 | // @ts-ignore 26 | label, 27 | type = "text", 28 | className = "", 29 | ...props 30 | }) { 31 | return ( 32 |
33 | {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} 34 | {label && } 35 | {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} 36 | 37 |
38 | ); 39 | } 40 | 41 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 42 | // @ts-ignore 43 | export function SelectField({ id, label, className = "", ...props }) { 44 | return ( 45 |
46 | {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} 47 | {label && } 48 | {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */} 49 | setTitle(e.target.value)} 53 | onKeyDown={handleEnterKeyPress(() => 54 | updateProjectTitle({ 55 | projectId, 56 | title, 57 | }) 58 | )} 59 | /> 60 |
61 |
62 | ) : ( 63 |

{projectTitle}

64 | )} 65 | {isUpdatingProjectTitle && } 66 | {isEditingTitle && !isUpdatingProjectTitle && ( 67 | 68 | )} 69 | {isEditingProject && !isEditingTitle && !isUpdatingProjectTitle && ( 70 |
71 | setIsEditingTitle(true)} 74 | /> 75 |
76 | )} 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/ProjectsV2/PurchaseNudge.tsx: -------------------------------------------------------------------------------- 1 | import EndOfTheRoad from "~/components/Images/EndOfTheRoad"; 2 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 3 | import React, { useEffect, useState } from "react"; 4 | import { SignUpButton, useUser } from "@clerk/nextjs"; 5 | import { usePostHog } from "posthog-js/react"; 6 | import { api } from "~/utils/api"; 7 | 8 | type Props = { 9 | projectId: string; 10 | stripePriceId: string; 11 | instructionId: string; 12 | }; 13 | export default function PurchaseNudge({ 14 | projectId, 15 | stripePriceId, 16 | instructionId, 17 | }: Props) { 18 | const [isRedirectingToStripe, setIsRedirectingToStripe] = useState(false); 19 | const [redirectUrl, setRedirectUrl] = useState(""); 20 | const postHog = usePostHog(); 21 | const { user } = useUser(); 22 | 23 | useEffect(() => { 24 | setRedirectUrl(window.location.href); 25 | }, []); 26 | 27 | const { data: price } = api.stripe.getPrices.useQuery( 28 | { 29 | priceId: stripePriceId, 30 | }, 31 | { 32 | refetchOnWindowFocus: false, 33 | } 34 | ); 35 | 36 | return ( 37 |
40 | 41 |

42 | {" "} 43 | {"You've reached the end of your free preview."}{" "} 44 |

45 |

46 | {" "} 47 | { 48 | "To continue with the tutorial, you must purchase the full tutorial with the link below." 49 | }{" "} 50 |

51 | 52 | {user ? ( 53 |
62 | 85 |
86 | ) : ( 87 | 88 | 95 | 96 | )} 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/ProjectsV2/QuestionsPurchaseNudge.tsx: -------------------------------------------------------------------------------- 1 | import EndOfTheRoad from "~/components/Images/EndOfTheRoad"; 2 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 3 | import React, { useEffect, useState } from "react"; 4 | import { SignUpButton, useUser } from "@clerk/nextjs"; 5 | import { usePostHog } from "posthog-js/react"; 6 | import { api } from "~/utils/api"; 7 | 8 | type Props = { 9 | projectId: string; 10 | stripePriceId: string; 11 | instructionId: string; 12 | }; 13 | export default function QuestionsPurchaseNudge({ 14 | projectId, 15 | stripePriceId, 16 | instructionId, 17 | }: Props) { 18 | const [isRedirectingToStripe, setIsRedirectingToStripe] = useState(false); 19 | const postHog = usePostHog(); 20 | const { user } = useUser(); 21 | const [redirectUrl, setRedirectUrl] = useState(""); 22 | 23 | useEffect(() => { 24 | setRedirectUrl(window.location.href); 25 | }, []); 26 | 27 | const { data: price } = api.stripe.getPrices.useQuery( 28 | { 29 | priceId: stripePriceId, 30 | }, 31 | { 32 | refetchOnWindowFocus: false, 33 | } 34 | ); 35 | 36 | return ( 37 |
40 | 41 |

42 | {" "} 43 | {"To access the Q&A section, you must purchase the full tutorial."}{" "} 44 |

45 | 46 | {user ? ( 47 |
56 | 79 |
80 | ) : ( 81 | 82 | 89 | 90 | )} 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/ProjectsV2/TableOfContentBlock.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 2 | import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/solid"; 3 | import { useRouter } from "next/router"; 4 | import { api } from "~/utils/api"; 5 | import { toast } from "react-hot-toast"; 6 | import { type KeyboardEvent, useState } from "react"; 7 | 8 | type Props = { 9 | entry: { id: string; title: string }; 10 | instructionId: string; 11 | projectId: string; 12 | index: number; 13 | isEditingProject: boolean; 14 | }; 15 | export default function TableOfContentBlock({ 16 | entry, 17 | instructionId, 18 | projectId, 19 | index, 20 | isEditingProject, 21 | }: Props) { 22 | const [isEditingTitle, setIsEditingTitle] = useState(false); 23 | const [title, setTitle] = useState(entry.title); 24 | const router = useRouter(); 25 | 26 | const { mutate: deleteInstruction, isLoading: isDeletingInstruction } = 27 | api.instructions.delete.useMutation({ 28 | onSuccess: () => { 29 | void router.replace(router.asPath); 30 | }, 31 | onError: (e) => { 32 | const errorMessage = e.data?.zodError?.fieldErrors.content; 33 | if (errorMessage && errorMessage[0]) { 34 | toast.error(errorMessage[0]); 35 | } else { 36 | toast.error( 37 | "Failed to create a new instruction. Please try again later." 38 | ); 39 | } 40 | }, 41 | }); 42 | 43 | const { mutate: updateInstruction, isLoading: isUpdatingInstruction } = 44 | api.instructions.update.useMutation({ 45 | onSuccess: () => { 46 | void router.replace(router.asPath); 47 | setIsEditingTitle(false); 48 | }, 49 | }); 50 | 51 | function handleEnterKeyPress(f: () => void) { 52 | return handleKeyPress(f, "Enter"); 53 | } 54 | 55 | function handleKeyPress(f: () => void, key: string) { 56 | return (e: KeyboardEvent) => { 57 | if (e.key === key) { 58 | f(); 59 | } 60 | }; 61 | } 62 | 63 | return ( 64 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/components/ProjectsV2/TextExplanationComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { api } from "~/utils/api"; 3 | import { toast } from "react-hot-toast"; 4 | import { debounce } from "throttle-debounce"; 5 | import "@uiw/react-md-editor/markdown-editor.css"; 6 | import "@uiw/react-markdown-preview/markdown.css"; 7 | import MdEditor from "~/components/Common/MdEditor/MdEditor"; 8 | 9 | type Props = { 10 | instructionId: string; 11 | readOnly: boolean; 12 | isAuthor: boolean; 13 | initialExplanation: string; 14 | }; 15 | export default function TextExplanationComponent({ 16 | instructionId, 17 | readOnly, 18 | isAuthor, 19 | initialExplanation, 20 | }: Props) { 21 | const [explanation, setExplanation] = useState( 22 | initialExplanation 23 | ); 24 | const [isInitialized, setIsInitialized] = useState(false); 25 | 26 | const debouncedSave = useCallback( 27 | debounce(1000, (instructionId: string, explanation: string) => { 28 | mutate({ instructionId, explanation }); 29 | }), 30 | [] 31 | ); 32 | 33 | useEffect(() => { 34 | try { 35 | if (explanation && isAuthor) { 36 | debouncedSave(instructionId, explanation); 37 | } 38 | } catch (error: any) {} 39 | }, [explanation, debouncedSave]); 40 | 41 | const { mutate } = api.instructions.update.useMutation({ 42 | onSuccess: () => { 43 | if (!isInitialized) { 44 | setIsInitialized(true); 45 | } else { 46 | // Show successfully saved state 47 | toast.success("Saved!"); 48 | } 49 | }, 50 | onError: (e) => { 51 | const errorMessage = e.data?.zodError?.fieldErrors.content; 52 | if (errorMessage && errorMessage[0]) { 53 | toast.error(errorMessage[0]); 54 | } else { 55 | toast.error("Failed to save! Please try again later."); 56 | } 57 | }, 58 | }); 59 | 60 | return ( 61 |
65 | 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/QuestionsAndAnswers/CommentBox.tsx: -------------------------------------------------------------------------------- 1 | import MdEditor from "~/components/Common/MdEditor/MdEditor"; 2 | import React, { useState } from "react"; 3 | import { api } from "~/utils/api"; 4 | import { toast } from "react-hot-toast"; 5 | import { useUser } from "@clerk/nextjs"; 6 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 7 | 8 | type Props = { 9 | parentCommentId?: string; 10 | questionId: string; 11 | cancelCallback?: () => void; 12 | }; 13 | export default function CommentBox({ 14 | parentCommentId, 15 | questionId, 16 | cancelCallback, 17 | }: Props) { 18 | const [comment, setComment] = useState(""); 19 | const { user } = useUser(); 20 | const userId = user?.id; 21 | const username = user?.username; 22 | const ctx = api.useContext(); 23 | 24 | const { mutate, isLoading } = api.comments.create.useMutation({ 25 | onSuccess: () => { 26 | setComment(""); 27 | cancelCallback?.(); 28 | void ctx.comments.getAllCommentsForQuestion.invalidate({ questionId }); 29 | }, 30 | onError: (e) => { 31 | const errorMessage = e.data?.zodError?.fieldErrors.content; 32 | if (errorMessage && errorMessage[0]) { 33 | toast.error(errorMessage[0]); 34 | } else { 35 | toast.error("Failed to ask the question — please try again later."); 36 | } 37 | }, 38 | }); 39 | 40 | const postComment = () => { 41 | if (comment && userId) { 42 | mutate({ userId, comment, parentCommentId, questionId, username }); 43 | } 44 | }; 45 | 46 | return ( 47 |
51 |
52 | 53 | 61 |
62 | 73 | 74 | {cancelCallback && ( 75 | 82 | )} 83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/QuestionsAndAnswers/CommentsList.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "~/utils/api"; 2 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 3 | import EmptyComments from "~/components/Images/EmptyComments"; 4 | import { getRelativeDate } from "~/utils/utils"; 5 | import { useState } from "react"; 6 | import CommentBox from "~/components/QuestionsAndAnswers/CommentBox"; 7 | 8 | type Props = { 9 | questionId: string; 10 | }; 11 | export default function CommentsList({ questionId }: Props) { 12 | const [commentToReplyTo, setCommentToReplyTo] = useState(""); 13 | const { data, isFetching } = api.comments.getAllCommentsForQuestion.useQuery( 14 | { 15 | questionId, 16 | }, 17 | { 18 | refetchOnWindowFocus: false, 19 | } 20 | ); 21 | 22 | const cancelReplyToComment = () => setCommentToReplyTo(""); 23 | 24 | if (isFetching) 25 | return ( 26 |
27 | {" "} 28 | {" "} 29 |
30 | ); 31 | if (!data) { 32 | return ( 33 |
34 | {" "} 35 | {" "} 36 |

No comments found...yet 👀

37 |
38 | ); 39 | } 40 | return ( 41 |
42 | {data.map((comment) => ( 43 |
44 |
{comment.comment}
45 |
48 |
49 |
50 | {comment.username} 51 |
52 | 55 |
56 |

57 | {getRelativeDate(comment.createdAt)} 58 |

59 |
60 | {commentToReplyTo === comment.id && ( 61 |
62 | 67 |
68 | )} 69 | {comment.replies.map((reply) => ( 70 | <> 71 |
75 |
{reply.comment}
76 |
81 | 84 |

85 | {getRelativeDate(reply.createdAt)} 86 |

87 |
88 |
89 | 90 | {commentToReplyTo === reply.id && ( 91 |
92 | 97 |
98 | )} 99 | 100 | ))} 101 |
102 | ))} 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/QuestionsAndAnswers/QuestionAndAnswerComponent.tsx: -------------------------------------------------------------------------------- 1 | import QuestionBox from "~/components/QuestionsAndAnswers/QuestionBox"; 2 | import QuestionsList from "~/components/QuestionsAndAnswers/QuestionsList"; 3 | import FocusedQuestionComponent from "~/components/DraftProjects/FocusedQuestion"; 4 | import { useState } from "react"; 5 | import { type Questions } from "@prisma/client"; 6 | 7 | type Props = { 8 | instructionId: string; 9 | projectId: string; 10 | }; 11 | export default function QuestionAndAnswerComponent({ 12 | instructionId, 13 | projectId, 14 | }: Props) { 15 | const [focusedQuestion, setFocusedQuestion] = useState(); 16 | 17 | return ( 18 |
19 | {" "} 20 | {focusedQuestion ? ( 21 | 25 | ) : ( 26 | <> 27 | {" "} 28 |
29 | 33 | 34 | )} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/QuestionsAndAnswers/QuestionBox.tsx: -------------------------------------------------------------------------------- 1 | import MdEditor from "~/components/Common/MdEditor/MdEditor"; 2 | import React, { useState } from "react"; 3 | import { api } from "~/utils/api"; 4 | import { toast } from "react-hot-toast"; 5 | import { SignedIn, SignedOut, SignUpButton, useUser } from "@clerk/nextjs"; 6 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 7 | 8 | type Props = { 9 | instructionId: string; 10 | projectId: string; 11 | }; 12 | export default function QuestionBox({ instructionId, projectId }: Props) { 13 | const [title, setTitle] = useState(""); 14 | const [question, setQuestion] = useState(""); 15 | const { user } = useUser(); 16 | const userId = user?.id; 17 | const username = user?.username; 18 | const ctx = api.useContext(); 19 | 20 | const { mutate, isLoading } = api.questions.create.useMutation({ 21 | onSuccess: () => { 22 | setTitle(""); 23 | setQuestion(""); 24 | void ctx.questions.getAllQuestionsForInstruction.invalidate({ 25 | instructionsId: instructionId, 26 | }); 27 | }, 28 | onError: (e) => { 29 | const errorMessage = e.data?.zodError?.fieldErrors.content; 30 | if (errorMessage && errorMessage[0]) { 31 | toast.error(errorMessage[0]); 32 | } else { 33 | toast.error("Failed to ask the question — please try again later."); 34 | } 35 | }, 36 | }); 37 | 38 | const postQuestion = () => { 39 | if (title && question && userId) { 40 | mutate({ 41 | userId, 42 | instructionsId: instructionId, 43 | question, 44 | title, 45 | username, 46 | }); 47 | } 48 | }; 49 | 50 | return ( 51 |
55 |

56 | {" "} 57 | Ask a question below{" "} 58 |

59 |
60 | 64 | setTitle(e.target.value)} 68 | /> 69 | 73 | 81 |
82 | 83 | 94 | 95 | 96 | 100 | 107 | 108 | 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/components/QuestionsAndAnswers/QuestionsList.tsx: -------------------------------------------------------------------------------- 1 | import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline"; 2 | import { getRelativeDate } from "~/utils/utils"; 3 | import { api } from "~/utils/api"; 4 | import LoadingSpinner from "~/components/Common/LoadingSpinner"; 5 | import { type Dispatch, type SetStateAction } from "react"; 6 | import { type Questions } from "@prisma/client"; 7 | import EmptyComments from "~/components/Images/EmptyComments"; 8 | 9 | type Props = { 10 | instructionId: string; 11 | setFocusedQuestion: Dispatch>; 12 | }; 13 | export default function QuestionsList({ 14 | instructionId, 15 | setFocusedQuestion, 16 | }: Props) { 17 | const { data, isFetching } = 18 | api.questions.getAllQuestionsForInstruction.useQuery( 19 | { 20 | instructionsId: instructionId, 21 | }, 22 | { 23 | refetchOnWindowFocus: false, 24 | } 25 | ); 26 | 27 | if (isFetching) { 28 | return ( 29 |
30 | {" "} 31 | {" "} 32 |
33 | ); 34 | } 35 | 36 | if (!data || data.length === 0) { 37 | return ( 38 |
39 |

No questions asked...yet 👀

40 |

41 | {" "} 42 | Ask the first question to get the discussion started{" "} 43 |

44 | 45 |
46 | ); 47 | } 48 | 49 | return ( 50 |
51 | {" "} 52 | {data?.map((question) => ( 53 |
setFocusedQuestion(question)} 59 | > 60 |
{question.title}
61 |
62 |
63 |
64 | 65 | {question._count.comments} 66 |
67 |
68 | {question.username} 69 |
70 |
71 | 72 | {getRelativeDate(question.createdAt)} 73 |
74 |
75 | ))} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Specify your server-side environment variables schema here. This way you can ensure the app isn't 5 | * built with invalid env vars. 6 | */ 7 | const server = z.object({ 8 | DATABASE_URL: z.string().url(), 9 | NODE_ENV: z.enum(["development", "test", "production"]), 10 | }); 11 | 12 | /** 13 | * Specify your client-side environment variables schema here. This way you can ensure the app isn't 14 | * built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`. 15 | */ 16 | const client = z.object({ 17 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 18 | }); 19 | 20 | /** 21 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 22 | * middlewares) or client-side so we need to destruct manually. 23 | * 24 | * @type {Record | keyof z.infer, string | undefined>} 25 | */ 26 | const processEnv = { 27 | DATABASE_URL: process.env.DATABASE_URL, 28 | NODE_ENV: process.env.NODE_ENV, 29 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 30 | }; 31 | 32 | // Don't touch the part below 33 | // -------------------------- 34 | 35 | const merged = server.merge(client); 36 | 37 | /** @typedef {z.input} MergedInput */ 38 | /** @typedef {z.infer} MergedOutput */ 39 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */ 40 | 41 | let env = /** @type {MergedOutput} */ (process.env); 42 | 43 | if (!!process.env.SKIP_ENV_VALIDATION == false) { 44 | const isServer = typeof window === "undefined"; 45 | 46 | const parsed = /** @type {MergedSafeParseReturn} */ ( 47 | isServer 48 | ? merged.safeParse(processEnv) // on server we can validate all env vars 49 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed 50 | ); 51 | 52 | if (parsed.success === false) { 53 | console.error( 54 | "❌ Invalid environment variables:", 55 | parsed.error.flatten().fieldErrors, 56 | ); 57 | throw new Error("Invalid environment variables"); 58 | } 59 | 60 | env = new Proxy(parsed.data, { 61 | get(target, prop) { 62 | if (typeof prop !== "string") return undefined; 63 | // Throw a descriptive error if a server-side env var is accessed on the client 64 | // Otherwise it would just be returning `undefined` and be annoying to debug 65 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_")) 66 | throw new Error( 67 | process.env.NODE_ENV === "production" 68 | ? "❌ Attempted to access a server-side environment variable on the client" 69 | : `❌ Attempted to access server-side environment variable '${prop}' on the client`, 70 | ); 71 | return target[/** @type {keyof typeof target} */ (prop)]; 72 | }, 73 | }); 74 | } 75 | 76 | export { env }; 77 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { withClerkMiddleware } from "@clerk/nextjs/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export default withClerkMiddleware(() => { 5 | return NextResponse.next(); 6 | }); 7 | 8 | export const config = { 9 | matcher: "/((?!_next/image|_next/static|favicon.ico).*)", 10 | }; 11 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ClerkProvider } from "@clerk/nextjs"; 2 | import { type AppType } from "next/app"; 3 | import { api } from "~/utils/api"; 4 | import "~/styles/globals.css"; 5 | import { Analytics } from "@vercel/analytics/react"; 6 | import { useEffect } from "react"; 7 | import posthog from "posthog-js"; 8 | import { PostHogProvider } from "posthog-js/react"; 9 | import { useRouter } from "next/router"; 10 | import Script from "next/script"; 11 | 12 | if (typeof window !== "undefined" && process.env.NODE_ENV === "production") { 13 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY ?? "", { 14 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com", 15 | // Enable debug mode in development 16 | loaded: (posthog) => { 17 | if (process.env.NODE_ENV === "development") posthog.debug(); 18 | }, 19 | }); 20 | } 21 | 22 | const MyApp: AppType = ({ Component, pageProps }) => { 23 | const router = useRouter(); 24 | useEffect(() => { 25 | // Track page views 26 | const handleRouteChange = () => posthog?.capture("$pageview"); 27 | router.events.on("routeChangeComplete", handleRouteChange); 28 | 29 | return () => { 30 | router.events.off("routeChangeComplete", handleRouteChange); 31 | }; 32 | }, []); 33 | return ( 34 | 35 | 36 | 37 |