├── .dockerignore ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── cors.xml ├── docker-compose.yml ├── next.config.mjs ├── package-lock.json ├── package.json ├── prisma └── schema.prisma ├── public └── favicon.ico ├── s3.js ├── s3 ├── .gitignore ├── error.html └── index.html ├── src ├── components │ ├── layouts │ │ └── admin-dashboard-layout.tsx │ └── shell │ │ ├── _main-links.tsx │ │ └── _user.tsx ├── env.mjs ├── middleware.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── authSSR.ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── dashboard │ │ ├── courses │ │ │ ├── [courseId].tsx │ │ │ └── index.tsx │ │ └── index.tsx │ └── index.tsx ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── course.ts │ │ │ └── example.ts │ │ └── trpc.ts │ ├── auth.ts │ └── db.ts ├── styles │ └── globals.css └── utils │ ├── api.ts │ └── getImageUrl.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.next -------------------------------------------------------------------------------- /.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 | 16 | # Next Auth 17 | # You can generate a new secret on the command line with: 18 | # openssl rand -base64 32 19 | # https://next-auth.js.org/configuration/options#secret 20 | # NEXTAUTH_SECRET="" 21 | NEXTAUTH_URL="http://localhost:3000" 22 | 23 | # Next Auth Discord Provider 24 | GOOGLE_CLIENT_ID="replace_me" 25 | GOOGLE_CLIENT_SECRET="replace_me" 26 | -------------------------------------------------------------------------------- /.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 | rules: { 16 | "@typescript-eslint/no-misused-promises": "off", 17 | }, 18 | }, 19 | ], 20 | parser: "@typescript-eslint/parser", 21 | parserOptions: { 22 | project: path.join(__dirname, "tsconfig.json"), 23 | }, 24 | plugins: ["@typescript-eslint"], 25 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 26 | rules: { 27 | "@typescript-eslint/consistent-type-imports": [ 28 | "warn", 29 | { 30 | prefer: "type-imports", 31 | fixStyle: "inline-type-imports", 32 | }, 33 | ], 34 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 35 | }, 36 | }; 37 | 38 | module.exports = config; 39 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 node:18 2 | RUN apt-get update -y && apt-get upgrade -y 3 | 4 | RUN apt-get install -y unzip sudo build-essential 5 | 6 | RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" 7 | RUN unzip awscliv2.zip 8 | RUN sudo ./aws/install 9 | 10 | WORKDIR /home/app 11 | 12 | COPY . . 13 | 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Web Dev Cody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create T3 App 2 | 3 | This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. 4 | 5 | ## What's next? How do I make an app with this? 6 | 7 | We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. 8 | 9 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. 10 | 11 | - [Next.js](https://nextjs.org) 12 | - [NextAuth.js](https://next-auth.js.org) 13 | - [Prisma](https://prisma.io) 14 | - [Tailwind CSS](https://tailwindcss.com) 15 | - [tRPC](https://trpc.io) 16 | 17 | ## Learn More 18 | 19 | To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: 20 | 21 | - [Documentation](https://create.t3.gg/) 22 | - [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials 23 | 24 | You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! 25 | 26 | ## How do I deploy this? 27 | 28 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 29 | 30 | # If you run out of disk space, delete this directory on mac (note to myself) 31 | 32 | /Users/webdevcody/Library/Containers/com.docker.docker/Data/vms/0/data 33 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Must Have 4 | 5 | - add loading state when creating and uploading a new section / file 6 | - adding loading state when uploading a new course image 7 | - add a description to the section (mantine has a markdown editor) 8 | - delete the file from s3 when deleting a section 9 | - section video "required" doesn't seem to be working; integrated that with mantine form 10 | - disable button while submitting 11 | 12 | ## Could Live Without 13 | 14 | - ability to re-order the sections 15 | 16 | # Features We Need 17 | 18 | - create / edit a course landing page 19 | - a user can create an account (next-auth google authentication) 20 | - the ability for a course creator to upload video files 21 | - title those videos 22 | - description on those videos 23 | - delete a part of the course 24 | - edit a part of the course 25 | - stripe payments for a paywall 26 | 27 | ## Nice to Have Features 28 | 29 | - users could have discussion about parts of the course 30 | - track progress on videos 31 | - users should be able to navigate forward and backwards through the course 32 | - refunds 33 | - coupons for course 34 | - analytics on user watch time 35 | - how many users actually finish your course 36 | - track when people buy courses 37 | - magic email link authentication 38 | - analytics on how much money was made daily 39 | - analytics on how many people viewed your course landing page 40 | -------------------------------------------------------------------------------- /cors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | POST 6 | GET 7 | 3000 8 | Authorization 9 | 10 | 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | db: 4 | image: postgres 5 | restart: always 6 | ports: 7 | - 5432:5432 8 | environment: 9 | POSTGRES_PASSWORD: example 10 | PGDATA: /data/postgres 11 | volumes: 12 | - postgres:/data/postgres 13 | 14 | adminer: 15 | image: adminer 16 | restart: always 17 | ports: 18 | - 8080:8080 19 | 20 | s3-bucket: 21 | build: . 22 | restart: always 23 | ports: 24 | - 9000:9000 25 | command: "node s3.js" 26 | volumes: 27 | - ".:/home/app" 28 | 29 | volumes: 30 | postgres: 31 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.mjs"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | reactStrictMode: true, 10 | 11 | /** 12 | * If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config 13 | * out. 14 | * 15 | * @see https://github.com/vercel/next.js/issues/41980 16 | */ 17 | i18n: { 18 | locales: ["en"], 19 | defaultLocale: "en", 20 | }, 21 | images: { 22 | domains: ["googleusercontent.com"], 23 | }, 24 | }; 25 | export default config; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "online-course-platform", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint": "next lint", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "@aws-sdk/client-s3": "^3.328.0", 14 | "@aws-sdk/s3-presigned-post": "^3.328.0", 15 | "@aws-sdk/s3-request-presigner": "^3.328.0", 16 | "@emotion/react": "^11.10.6", 17 | "@emotion/server": "^11.10.0", 18 | "@mantine/carousel": "^6.0.8", 19 | "@mantine/core": "^6.0.8", 20 | "@mantine/dates": "^6.0.8", 21 | "@mantine/dropzone": "^6.0.8", 22 | "@mantine/form": "^6.0.8", 23 | "@mantine/hooks": "^6.0.8", 24 | "@mantine/modals": "^6.0.8", 25 | "@mantine/next": "^6.0.8", 26 | "@mantine/notifications": "^6.0.8", 27 | "@mantine/nprogress": "^6.0.8", 28 | "@mantine/prism": "^6.0.8", 29 | "@mantine/spotlight": "^6.0.8", 30 | "@mantine/tiptap": "^6.0.8", 31 | "@next-auth/prisma-adapter": "^1.0.5", 32 | "@prisma/client": "^4.11.0", 33 | "@tabler/icons-react": "^2.16.0", 34 | "@tanstack/react-query": "^4.28.0", 35 | "@tiptap/extension-link": "^2.0.3", 36 | "@tiptap/react": "^2.0.3", 37 | "@tiptap/starter-kit": "^2.0.3", 38 | "@trpc/client": "^10.18.0", 39 | "@trpc/next": "^10.18.0", 40 | "@trpc/react-query": "^10.18.0", 41 | "@trpc/server": "^10.18.0", 42 | "dayjs": "^1.11.7", 43 | "embla-carousel-react": "^7.1.0", 44 | "next": "^13.2.4", 45 | "next-auth": "^4.21.0", 46 | "react": "18.2.0", 47 | "react-dom": "18.2.0", 48 | "superjson": "1.12.2", 49 | "tabler-icons-react": "^1.56.0", 50 | "uuid": "^9.0.0", 51 | "zod": "^3.21.4" 52 | }, 53 | "devDependencies": { 54 | "@types/eslint": "^8.21.3", 55 | "@types/node": "^18.15.5", 56 | "@types/react": "^18.0.28", 57 | "@types/react-dom": "^18.0.11", 58 | "@types/s3rver": "^3.7.0", 59 | "@types/uuid": "^9.0.1", 60 | "@typescript-eslint/eslint-plugin": "^5.56.0", 61 | "@typescript-eslint/parser": "^5.56.0", 62 | "eslint": "^8.36.0", 63 | "eslint-config-next": "^13.2.4", 64 | "prisma": "^4.11.0", 65 | "s3rver": "^3.7.1", 66 | "typescript": "^5.0.2" 67 | }, 68 | "ct3aMetadata": { 69 | "initVersion": "7.11.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Example { 14 | id String @id @default(cuid()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | } 18 | 19 | // Necessary for Next auth 20 | model Account { 21 | id String @id @default(cuid()) 22 | userId String 23 | type String 24 | provider String 25 | providerAccountId String 26 | refresh_token String? // @db.Text 27 | access_token String? // @db.Text 28 | expires_at Int? 29 | token_type String? 30 | scope String? 31 | id_token String? // @db.Text 32 | session_state String? 33 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 34 | 35 | @@unique([provider, providerAccountId]) 36 | } 37 | 38 | model Session { 39 | id String @id @default(cuid()) 40 | sessionToken String @unique 41 | userId String 42 | expires DateTime 43 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 44 | } 45 | 46 | model User { 47 | id String @id @default(cuid()) 48 | name String? 49 | email String? @unique 50 | emailVerified DateTime? 51 | image String? 52 | accounts Account[] 53 | sessions Session[] 54 | Course Course[] 55 | } 56 | 57 | model VerificationToken { 58 | identifier String 59 | token String @unique 60 | expires DateTime 61 | 62 | @@unique([identifier, token]) 63 | } 64 | 65 | model Course { 66 | id String @id @default(cuid()) 67 | user User @relation(fields: [userId], references: [id]) 68 | userId String 69 | title String 70 | description String 71 | imageId String @default("") 72 | sections Section[] 73 | } 74 | 75 | model Section { 76 | id String @id @default(cuid()) 77 | title String 78 | videoId String 79 | course Course? @relation(fields: [courseId], references: [id]) 80 | courseId String? 81 | order Int @default(autoincrement()) 82 | } 83 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/online-course-platform/ee963ca009f886c33e2511d603d5d89e9526f5b4/public/favicon.ico -------------------------------------------------------------------------------- /s3.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const S3rver = require("s3rver"); 3 | 4 | console.log("we are here"); 5 | 6 | new S3rver({ 7 | port: 9000, 8 | address: "0.0.0.0", 9 | directory: "./s3", 10 | configureBuckets: [ 11 | { 12 | name: "wdc-online-course-platform", 13 | configs: [fs.readFileSync("./cors.xml")], 14 | }, 15 | ], 16 | }).run(); 17 | -------------------------------------------------------------------------------- /s3/.gitignore: -------------------------------------------------------------------------------- 1 | /wdc-online-course-platform -------------------------------------------------------------------------------- /s3/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /s3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/layouts/admin-dashboard-layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionIcon, 3 | AppShell, 4 | Group, 5 | Header, 6 | Navbar, 7 | useMantineColorScheme, 8 | } from "@mantine/core"; 9 | import { IconSun, IconMoonStars } from "@tabler/icons-react"; 10 | import { ReactNode } from "react"; 11 | import { MainLinks } from "../shell/_main-links"; 12 | import { User } from "../shell/_user"; 13 | 14 | export default function AdminDashboardLayout({ 15 | children, 16 | }: { 17 | children: ReactNode; 18 | }) { 19 | const { colorScheme, toggleColorScheme } = useMantineColorScheme(); 20 | 21 | return ( 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | } 34 | header={ 35 |
36 | 37 | X 38 | toggleColorScheme()} 41 | size={30} 42 | > 43 | {colorScheme === "dark" ? ( 44 | 45 | ) : ( 46 | 47 | )} 48 | 49 | 50 |
51 | } 52 | styles={(theme) => ({ 53 | main: { 54 | backgroundColor: 55 | theme.colorScheme === "dark" 56 | ? theme.colors.dark[8] 57 | : theme.colors.gray[0], 58 | }, 59 | })} 60 | > 61 | {children} 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/shell/_main-links.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconPencil, IconDashboard } from "@tabler/icons-react"; 3 | import { ThemeIcon, UnstyledButton, Group, Text } from "@mantine/core"; 4 | import Link from "next/link"; 5 | 6 | const links = [ 7 | { 8 | icon: , 9 | color: "red", 10 | label: "Dashboard", 11 | href: "/", 12 | }, 13 | { 14 | icon: , 15 | color: "blue", 16 | label: "Manage Course", 17 | href: "/dashboard/courses", 18 | }, 19 | ]; 20 | 21 | export function MainLinks() { 22 | return ( 23 | <> 24 | {links.map((link) => ( 25 | ({ 30 | display: "block", 31 | width: "100%", 32 | padding: theme.spacing.xs, 33 | borderRadius: theme.radius.sm, 34 | color: 35 | theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.black, 36 | 37 | "&:hover": { 38 | backgroundColor: 39 | theme.colorScheme === "dark" 40 | ? theme.colors.dark[6] 41 | : theme.colors.gray[0], 42 | }, 43 | })} 44 | > 45 | 46 | 47 | {link.icon} 48 | 49 | 50 | {link.label} 51 | 52 | 53 | ))} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/shell/_user.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | IconChevronRight, 4 | IconChevronLeft, 5 | IconPower, 6 | } from "@tabler/icons-react"; 7 | import { 8 | UnstyledButton, 9 | Group, 10 | Avatar, 11 | Text, 12 | Box, 13 | useMantineTheme, 14 | rem, 15 | Button, 16 | Menu, 17 | } from "@mantine/core"; 18 | import { signIn, signOut, useSession } from "next-auth/react"; 19 | 20 | export function User() { 21 | const theme = useMantineTheme(); 22 | 23 | const user = useSession(); 24 | 25 | return ( 26 | 36 | {user.data ? ( 37 | 54 | 55 | 56 | 57 | 62 | 63 | 64 | {user.data.user.name} 65 | 66 | 67 | {user.data.user.email} 68 | 69 | 70 | 71 | {theme.dir === "ltr" ? ( 72 | 73 | ) : ( 74 | 75 | )} 76 | 77 | 78 | 79 | 80 | signOut()} 82 | icon={} 83 | > 84 | Sign Out 85 | 86 | 87 | 88 | 89 | ) : ( 90 | 91 | )} 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /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 | NEXTAUTH_SECRET: 11 | process.env.NODE_ENV === "production" 12 | ? z.string().min(1) 13 | : z.string().min(1).optional(), 14 | NEXTAUTH_URL: z.preprocess( 15 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL 16 | // Since NextAuth.js automatically uses the VERCEL_URL if present. 17 | (str) => process.env.VERCEL_URL ?? str, 18 | // VERCEL_URL doesn't include `https` so it cant be validated as a URL 19 | process.env.VERCEL ? z.string().min(1) : z.string().url() 20 | ), 21 | // Add `.min(1) on ID and SECRET if you want to make sure they're not empty 22 | GOOGLE_CLIENT_ID: z.string(), 23 | GOOGLE_CLIENT_SECRET: z.string(), 24 | NEXT_PUBLIC_S3_BUCKET_NAME: z.string(), 25 | }); 26 | 27 | /** 28 | * Specify your client-side environment variables schema here. This way you can ensure the app isn't 29 | * built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`. 30 | */ 31 | const client = z.object({ 32 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 33 | NEXT_PUBLIC_S3_BUCKET_NAME: z.string(), 34 | }); 35 | 36 | /** 37 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 38 | * middlewares) or client-side so we need to destruct manually. 39 | * 40 | * @type {Record | keyof z.infer, string | undefined>} 41 | */ 42 | const processEnv = { 43 | DATABASE_URL: process.env.DATABASE_URL, 44 | NODE_ENV: process.env.NODE_ENV, 45 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 46 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 47 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 48 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 49 | NEXT_PUBLIC_S3_BUCKET_NAME: process.env.NEXT_PUBLIC_S3_BUCKET_NAME, 50 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 51 | }; 52 | 53 | // Don't touch the part below 54 | // -------------------------- 55 | 56 | const merged = server.merge(client); 57 | 58 | /** @typedef {z.input} MergedInput */ 59 | /** @typedef {z.infer} MergedOutput */ 60 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */ 61 | 62 | let env = /** @type {MergedOutput} */ (process.env); 63 | 64 | const skip = 65 | !!process.env.SKIP_ENV_VALIDATION && 66 | process.env.SKIP_ENV_VALIDATION !== "false" && 67 | process.env.SKIP_ENV_VALIDATION !== "0"; 68 | if (!skip) { 69 | const isServer = typeof window === "undefined"; 70 | 71 | const parsed = /** @type {MergedSafeParseReturn} */ ( 72 | isServer 73 | ? merged.safeParse(processEnv) // on server we can validate all env vars 74 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed 75 | ); 76 | 77 | if (parsed.success === false) { 78 | console.error( 79 | "❌ Invalid environment variables:", 80 | parsed.error.flatten().fieldErrors 81 | ); 82 | throw new Error("Invalid environment variables"); 83 | } 84 | 85 | env = new Proxy(parsed.data, { 86 | get(target, prop) { 87 | if (typeof prop !== "string") return undefined; 88 | // Throw a descriptive error if a server-side env var is accessed on the client 89 | // Otherwise it would just be returning `undefined` and be annoying to debug 90 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_")) 91 | throw new Error( 92 | process.env.NODE_ENV === "production" 93 | ? "❌ Attempted to access a server-side environment variable on the client" 94 | : `❌ Attempted to access server-side environment variable '${prop}' on the client` 95 | ); 96 | return target[/** @type {keyof typeof target} */ (prop)]; 97 | }, 98 | }); 99 | } 100 | 101 | export { env }; 102 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // middleware.ts 3 | import { NextResponse } from "next/server"; 4 | import type { NextRequest } from "next/server"; 5 | 6 | export default async function middleware(req: NextRequest) { 7 | const url = req.nextUrl.clone(); 8 | 9 | const { 10 | data: { auth }, 11 | } = await fetch(`${url.origin}/api/authSSR`, { 12 | headers: req.headers, 13 | }).then((res) => res.json()); 14 | 15 | url.search = new URLSearchParams(`callbackUrl=${url}`).toString(); 16 | url.pathname = `/api/auth/signin`; 17 | 18 | return !auth ? NextResponse.redirect(url) : NextResponse.next(); 19 | } 20 | 21 | export const config = { 22 | matcher: ["/dashboard", "/dashboard/courses"], 23 | }; 24 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { type AppType } from "next/app"; 2 | import { type Session } from "next-auth"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import { 5 | ColorScheme, 6 | ColorSchemeProvider, 7 | MantineProvider, 8 | } from "@mantine/core"; 9 | import { useColorScheme } from "@mantine/hooks"; 10 | import { api } from "~/utils/api"; 11 | import "~/styles/globals.css"; 12 | import { useState } from "react"; 13 | 14 | const MyApp: AppType<{ session: Session | null }> = ({ 15 | Component, 16 | pageProps: { session, ...pageProps }, 17 | }) => { 18 | const preferredColorScheme = "dark"; //useColorScheme(); 19 | const [colorScheme, setColorScheme] = 20 | useState(preferredColorScheme); 21 | const toggleColorScheme = (value?: ColorScheme) => 22 | setColorScheme(value || (colorScheme === "dark" ? "light" : "dark")); 23 | 24 | return ( 25 | 29 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default api.withTRPC(MyApp); 45 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { createGetInitialProps } from "@mantine/next"; 2 | import Document, { Head, Html, Main, NextScript } from "next/document"; 3 | 4 | const getInitialProps = createGetInitialProps(); 5 | 6 | export default class _Document extends Document { 7 | static getInitialProps = getInitialProps; 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authOptions } from "~/server/auth"; 3 | 4 | export default NextAuth(authOptions); 5 | -------------------------------------------------------------------------------- /src/pages/api/authSSR.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { getSession } from "next-auth/react"; 3 | 4 | export default async function handle( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const session = await getSession({ req }); 9 | res.json({ data: { auth: !!session } }); 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | 3 | import { env } from "~/env.mjs"; 4 | import { createTRPCContext } from "~/server/api/trpc"; 5 | import { appRouter } from "~/server/api/root"; 6 | 7 | // export API handler 8 | export default createNextApiHandler({ 9 | router: appRouter, 10 | createContext: createTRPCContext, 11 | onError: 12 | env.NODE_ENV === "development" 13 | ? ({ path, error }) => { 14 | console.error( 15 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 16 | ); 17 | } 18 | : undefined, 19 | }); 20 | -------------------------------------------------------------------------------- /src/pages/dashboard/courses/[courseId].tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | /* eslint-disable @next/next/no-img-element */ 3 | import { 4 | Button, 5 | Group, 6 | FileInput, 7 | TextInput, 8 | Title, 9 | Stack, 10 | } from "@mantine/core"; 11 | import { useForm } from "@mantine/form"; 12 | import { useDisclosure } from "@mantine/hooks"; 13 | import { IconCheck, IconEdit, IconLetterX } from "@tabler/icons-react"; 14 | import { type NextPage } from "next"; 15 | import Head from "next/head"; 16 | import { useRouter } from "next/router"; 17 | import { useState } from "react"; 18 | import AdminDashboardLayout from "~/components/layouts/admin-dashboard-layout"; 19 | import { api } from "~/utils/api"; 20 | import { getImageUrl } from "~/utils/getImageUrl"; 21 | 22 | async function uploadFileToS3({ 23 | getPresignedUrl, 24 | file, 25 | }: { 26 | getPresignedUrl: () => Promise<{ 27 | url: string; 28 | fields: Record; 29 | }>; 30 | file: File; 31 | }) { 32 | const { url, fields } = await getPresignedUrl(); 33 | const data: Record = { 34 | ...fields, 35 | "Content-Type": file.type, 36 | file, 37 | }; 38 | const formData = new FormData(); 39 | for (const name in data) { 40 | formData.append(name, data[name]); 41 | } 42 | await fetch(url, { 43 | method: "POST", 44 | body: formData, 45 | }); 46 | } 47 | 48 | const Courses: NextPage = () => { 49 | const router = useRouter(); 50 | const courseId = router.query.courseId as string; 51 | 52 | const updateCourseMutation = api.course.updateCourse.useMutation(); 53 | const createSectionMutation = api.course.createSection.useMutation(); 54 | const deleteSection = api.course.deleteSection.useMutation(); 55 | const swapSections = api.course.swapSections.useMutation(); 56 | 57 | const createPresignedUrlMutation = 58 | api.course.createPresignedUrl.useMutation(); 59 | const createPresignedUrlForVideoMutation = 60 | api.course.createPresignedUrlForVideo.useMutation(); 61 | 62 | const updateTitleForm = useForm({ 63 | initialValues: { 64 | title: "", 65 | }, 66 | }); 67 | 68 | const newSectionForm = useForm({ 69 | initialValues: { 70 | title: "", 71 | }, 72 | }); 73 | 74 | const [file, setFile] = useState(null); 75 | const [newSection, setNewSection] = useState(null); 76 | 77 | const courseQuery = api.course.getCourseById.useQuery( 78 | { 79 | courseId, 80 | }, 81 | { 82 | enabled: !!courseId, 83 | onSuccess(data) { 84 | updateTitleForm.setFieldValue("title", data?.title ?? ""); 85 | }, 86 | } 87 | ); 88 | 89 | const [isEditingTitle, { open: setEditTitle, close: unsetEditTitle }] = 90 | useDisclosure(false); 91 | 92 | const uploadImage = async (e: React.FormEvent) => { 93 | e.preventDefault(); 94 | if (!file) return; 95 | await uploadFileToS3({ 96 | getPresignedUrl: () => 97 | createPresignedUrlMutation.mutateAsync({ 98 | courseId, 99 | }), 100 | file, 101 | }); 102 | setFile(null); 103 | await courseQuery.refetch(); 104 | 105 | // if (fileRef.current) { 106 | // fileRef.current.value = ""; 107 | // } 108 | }; 109 | 110 | // const onFileChange = (e: React.FormEvent) => { 111 | // setFile(e.currentTarget.files?.[0]); 112 | // }; 113 | 114 | const sortedSections = (courseQuery.data?.sections ?? []).sort( 115 | (a, b) => a.order - b.order 116 | ); 117 | 118 | return ( 119 | <> 120 | 121 | Manage Courses 122 | 123 | 124 | 125 | 126 |
127 | 128 | 129 | {isEditingTitle ? ( 130 |
{ 132 | await updateCourseMutation.mutateAsync({ 133 | ...values, 134 | courseId, 135 | }); 136 | await courseQuery.refetch(); 137 | unsetEditTitle(); 138 | })} 139 | > 140 | 141 | 147 | 150 | 153 | 154 |
155 | ) : ( 156 | 157 | {courseQuery.data?.title} 158 | 161 | 162 | )} 163 | 164 | 165 | {courseQuery.data && ( 166 | an image of the course 171 | )} 172 | 173 |
174 | 179 | 180 | 190 | 191 |
192 |
193 | 194 | 195 | Sections 196 | 197 | {sortedSections.map((section, idx) => ( 198 | 205 | 206 | 207 | {section.title} 208 | 209 | {idx > 0 && ( 210 | 228 | )} 229 | {idx < sortedSections.length - 1 && ( 230 | 248 | )} 249 | 270 | 271 | 278 | 279 | ))} 280 | 281 | 287 |
{ 289 | if (!newSection) return; 290 | 291 | const section = await createSectionMutation.mutateAsync({ 292 | courseId: courseId, 293 | title: values.title, 294 | }); 295 | 296 | await uploadFileToS3({ 297 | getPresignedUrl: () => 298 | createPresignedUrlForVideoMutation.mutateAsync({ 299 | sectionId: section.id, 300 | }), 301 | file: newSection, 302 | }); 303 | setNewSection(null); 304 | await courseQuery.refetch(); 305 | newSectionForm.reset(); 306 | })} 307 | > 308 | 309 | Create New Section 310 | 311 | 318 | 319 | 325 | 326 | 335 | 336 |
337 |
338 |
339 |
340 |
341 |
342 | 343 | ); 344 | }; 345 | 346 | export default Courses; 347 | -------------------------------------------------------------------------------- /src/pages/dashboard/courses/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Course } from "@prisma/client"; 2 | import { type NextPage } from "next"; 3 | import Head from "next/head"; 4 | import { useForm } from "@mantine/form"; 5 | import AdminDashboardLayout from "~/components/layouts/admin-dashboard-layout"; 6 | import { 7 | Card as MantineCard, 8 | Image, 9 | Text, 10 | Flex, 11 | Grid, 12 | Button, 13 | Group, 14 | Modal, 15 | TextInput, 16 | Stack, 17 | Textarea, 18 | } from "@mantine/core"; 19 | import { useDisclosure } from "@mantine/hooks"; 20 | import { api } from "~/utils/api"; 21 | import Link from "next/link"; 22 | import { getImageUrl } from "~/utils/getImageUrl"; 23 | 24 | export function CourseCard({ course }: { course: Course }) { 25 | return ( 26 | 27 | 28 | Norway 29 | 30 | 31 | 32 | {course.title} 33 | {/* 34 | On Sale 35 | */} 36 | 37 | 38 | 39 | {course.description} 40 | 41 | 42 | 53 | 54 | ); 55 | } 56 | 57 | const Courses: NextPage = () => { 58 | const courses = api.course.getCourses.useQuery(); 59 | 60 | const createCourseMutation = api.course.createCourse.useMutation(); 61 | 62 | const [ 63 | isCreateCourseModalOpen, 64 | { open: openCreateCourseModal, close: closeCreateCourseModal }, 65 | ] = useDisclosure(false); 66 | 67 | const createCourseForm = useForm({ 68 | initialValues: { 69 | title: "", 70 | description: "", 71 | }, 72 | }); 73 | 74 | return ( 75 | <> 76 | 77 | Manage Courses 78 | 79 | 80 | 81 | 86 |
{ 88 | await createCourseMutation.mutateAsync(values); 89 | closeCreateCourseModal(); 90 | createCourseForm.reset(); 91 | await courses.refetch(); 92 | })} 93 | > 94 | 95 | 102 | 103 |