├── .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 |
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 |
155 | ) : (
156 |
157 | {courseQuery.data?.title}
158 |
161 |
162 | )}
163 |
164 |
165 | {courseQuery.data && (
166 |
171 | )}
172 |
173 |
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 |
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 |
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 |
117 |
118 |
119 |
120 |
121 | Manage Courses
122 |
123 |
124 |
125 |
126 |
127 | {courses.data?.map((course) => (
128 |
129 |
130 |
131 | ))}
132 |
133 |
134 |
135 | >
136 | );
137 | };
138 |
139 | export default Courses;
140 |
--------------------------------------------------------------------------------
/src/pages/dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import { type NextPage } from "next";
2 | import Head from "next/head";
3 | import AdminDashboardLayout from "~/components/layouts/admin-dashboard-layout";
4 |
5 | const Dashboard: NextPage = () => {
6 | return (
7 | <>
8 |
9 | Create T3 App
10 |
11 |
12 |
13 |
14 | Dashboard
15 |
16 | >
17 | );
18 | };
19 |
20 | export default Dashboard;
21 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { type NextPage } from "next";
2 | import Head from "next/head";
3 |
4 | const Home: NextPage = () => {
5 | return (
6 | <>
7 |
8 | Create T3 App
9 |
10 |
11 |
12 | LANDING PAGE
13 | >
14 | );
15 | };
16 |
17 | export default Home;
18 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter } from "~/server/api/trpc";
2 | import { exampleRouter } from "~/server/api/routers/example";
3 | import { courseRouter } from "./routers/course";
4 |
5 | /**
6 | * This is the primary router for your server.
7 | *
8 | * All routers added in /api/routers should be manually added here.
9 | */
10 | export const appRouter = createTRPCRouter({
11 | example: exampleRouter,
12 | course: courseRouter,
13 | });
14 |
15 | // export type definition of API
16 | export type AppRouter = typeof appRouter;
17 |
--------------------------------------------------------------------------------
/src/server/api/routers/course.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
4 | import { S3Client } from "@aws-sdk/client-s3";
5 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
6 | import { env } from "~/env.mjs";
7 | import { TRPCError } from "@trpc/server";
8 | import { v4 as uuidv4 } from "uuid";
9 |
10 | const UPLOAD_MAX_FILE_SIZE = 1000000;
11 |
12 | const s3Client = new S3Client({
13 | region: "us-east-1",
14 | endpoint: "http://localhost:9000",
15 | forcePathStyle: true,
16 | credentials: {
17 | accessKeyId: "S3RVER",
18 | secretAccessKey: "S3RVER",
19 | },
20 | });
21 |
22 | export const courseRouter = createTRPCRouter({
23 | getCourseById: protectedProcedure
24 | .input(
25 | z.object({
26 | courseId: z.string(),
27 | })
28 | )
29 | .query(({ ctx, input }) => {
30 | return ctx.prisma.course.findUnique({
31 | where: {
32 | id: input.courseId,
33 | },
34 | include: {
35 | sections: true,
36 | },
37 | });
38 | }),
39 | getCourses: protectedProcedure.query(({ ctx }) => {
40 | return ctx.prisma.course.findMany({
41 | where: {
42 | userId: ctx.session.user.id,
43 | },
44 | });
45 | }),
46 | createCourse: protectedProcedure
47 | .input(z.object({ title: z.string(), description: z.string() }))
48 | .mutation(async ({ ctx, input }) => {
49 | const userId = ctx.session.user.id;
50 | const newCourse = await ctx.prisma.course.create({
51 | data: {
52 | title: input.title,
53 | description: input.description,
54 | userId: userId,
55 | },
56 | });
57 | return newCourse;
58 | }),
59 | updateCourse: protectedProcedure
60 | .input(z.object({ title: z.string(), courseId: z.string() }))
61 | .mutation(async ({ ctx, input }) => {
62 | const userId = ctx.session.user.id;
63 | await ctx.prisma.course.updateMany({
64 | where: {
65 | id: input.courseId,
66 | userId,
67 | },
68 | data: {
69 | title: input.title,
70 | },
71 | });
72 | return { status: "updated" };
73 | }),
74 | createPresignedUrl: protectedProcedure
75 | .input(z.object({ courseId: z.string() }))
76 | .mutation(async ({ ctx, input }) => {
77 | // const userId = ctx.session.user.id;
78 | const course = await ctx.prisma.course.findUnique({
79 | where: {
80 | id: input.courseId,
81 | },
82 | });
83 |
84 | if (!course) {
85 | throw new TRPCError({
86 | code: "NOT_FOUND",
87 | message: "the course does not exist",
88 | });
89 | }
90 |
91 | const imageId = uuidv4();
92 | await ctx.prisma.course.update({
93 | where: {
94 | id: course.id,
95 | },
96 | data: {
97 | imageId,
98 | },
99 | });
100 |
101 | return createPresignedPost(s3Client, {
102 | Bucket: env.NEXT_PUBLIC_S3_BUCKET_NAME,
103 | Key: imageId,
104 | Fields: {
105 | key: imageId,
106 | },
107 | Conditions: [
108 | ["starts-with", "$Content-Type", "image/"],
109 | ["content-length-range", 0, UPLOAD_MAX_FILE_SIZE],
110 | ],
111 | });
112 | }),
113 | createSection: protectedProcedure
114 | .input(z.object({ courseId: z.string(), title: z.string() }))
115 | .mutation(async ({ ctx, input }) => {
116 | const videoId = uuidv4();
117 | return await ctx.prisma.section.create({
118 | data: {
119 | videoId,
120 | title: input.title,
121 | courseId: input.courseId,
122 | },
123 | });
124 | }),
125 | deleteSection: protectedProcedure
126 | .input(z.object({ sectionId: z.string() }))
127 | .mutation(async ({ ctx, input }) => {
128 | const section = await ctx.prisma.section.delete({
129 | where: {
130 | id: input.sectionId,
131 | },
132 | });
133 |
134 | if (!section) {
135 | throw new TRPCError({
136 | code: "NOT_FOUND",
137 | message: "section not found",
138 | });
139 | }
140 |
141 | if (!section.courseId) {
142 | throw new TRPCError({
143 | code: "NOT_FOUND",
144 | message: "section has no course",
145 | });
146 | }
147 |
148 | const course = await ctx.prisma.course.findUnique({
149 | where: {
150 | id: section.courseId,
151 | },
152 | });
153 |
154 | if (course?.userId !== ctx.session.user.id) {
155 | throw new TRPCError({
156 | code: "FORBIDDEN",
157 | message: "you do not have access to this course",
158 | });
159 | }
160 |
161 | return section;
162 | }),
163 | swapSections: protectedProcedure
164 | .input(
165 | z.object({ sectionIdSource: z.string(), sectionIdTarget: z.string() })
166 | )
167 | .mutation(async ({ ctx, input }) => {
168 | const sectionSource = await ctx.prisma.section.findUnique({
169 | where: {
170 | id: input.sectionIdSource,
171 | },
172 | });
173 | if (!sectionSource) {
174 | throw new TRPCError({
175 | code: "NOT_FOUND",
176 | message: "the source section does not exist",
177 | });
178 | }
179 |
180 | const sectionTarget = await ctx.prisma.section.findUnique({
181 | where: {
182 | id: input.sectionIdTarget,
183 | },
184 | });
185 | if (!sectionTarget) {
186 | throw new TRPCError({
187 | code: "NOT_FOUND",
188 | message: "the target section does not exist",
189 | });
190 | }
191 |
192 | await ctx.prisma.section.update({
193 | where: {
194 | id: input.sectionIdSource,
195 | },
196 | data: {
197 | order: sectionTarget.order,
198 | },
199 | });
200 |
201 | await ctx.prisma.section.update({
202 | where: {
203 | id: input.sectionIdTarget,
204 | },
205 | data: {
206 | order: sectionSource.order,
207 | },
208 | });
209 | }),
210 | createPresignedUrlForVideo: protectedProcedure
211 | .input(z.object({ sectionId: z.string() }))
212 | .mutation(async ({ ctx, input }) => {
213 | const section = await ctx.prisma.section.findUnique({
214 | where: {
215 | id: input.sectionId,
216 | },
217 | });
218 |
219 | if (!section) {
220 | throw new TRPCError({
221 | code: "NOT_FOUND",
222 | message: "the section does not exist",
223 | });
224 | }
225 |
226 | if (!section.courseId) {
227 | throw new TRPCError({
228 | code: "NOT_FOUND",
229 | message: "the section has no course",
230 | });
231 | }
232 |
233 | const course = await ctx.prisma.course.findUnique({
234 | where: {
235 | id: section.courseId,
236 | },
237 | });
238 |
239 | if (course?.userId !== ctx.session.user.id) {
240 | throw new TRPCError({
241 | code: "FORBIDDEN",
242 | message: "you do not have access to this course",
243 | });
244 | }
245 |
246 | return createPresignedPost(s3Client, {
247 | Bucket: env.NEXT_PUBLIC_S3_BUCKET_NAME,
248 | Key: section.videoId,
249 | Fields: {
250 | key: section.videoId,
251 | },
252 | Conditions: [
253 | ["starts-with", "$Content-Type", "image/"],
254 | ["content-length-range", 0, UPLOAD_MAX_FILE_SIZE],
255 | ],
256 | });
257 | }),
258 | });
259 |
--------------------------------------------------------------------------------
/src/server/api/routers/example.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | import {
4 | createTRPCRouter,
5 | publicProcedure,
6 | protectedProcedure,
7 | } from "~/server/api/trpc";
8 |
9 | export const exampleRouter = createTRPCRouter({
10 | hello: publicProcedure
11 | .input(z.object({ text: z.string() }))
12 | .query(({ input }) => {
13 | return {
14 | greeting: `Hello ${input.text}`,
15 | };
16 | }),
17 |
18 | getAll: publicProcedure.query(({ ctx }) => {
19 | return ctx.prisma.example.findMany();
20 | }),
21 |
22 | getSecretMessage: protectedProcedure.query(() => {
23 | return "you can now see this secret message!";
24 | }),
25 | });
26 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
7 | * need to use are documented accordingly near the end.
8 | */
9 |
10 | /**
11 | * 1. CONTEXT
12 | *
13 | * This section defines the "contexts" that are available in the backend API.
14 | *
15 | * These allow you to access things when processing a request, like the database, the session, etc.
16 | */
17 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
18 | import { type Session } from "next-auth";
19 |
20 | import { getServerAuthSession } from "~/server/auth";
21 | import { prisma } from "~/server/db";
22 |
23 | type CreateContextOptions = {
24 | session: Session | null;
25 | };
26 |
27 | /**
28 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
29 | * it from here.
30 | *
31 | * Examples of things you may need it for:
32 | * - testing, so we don't have to mock Next.js' req/res
33 | * - tRPC's `createSSGHelpers`, where we don't have req/res
34 | *
35 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts
36 | */
37 | const createInnerTRPCContext = (opts: CreateContextOptions) => {
38 | return {
39 | session: opts.session,
40 | prisma,
41 | };
42 | };
43 |
44 | /**
45 | * This is the actual context you will use in your router. It will be used to process every request
46 | * that goes through your tRPC endpoint.
47 | *
48 | * @see https://trpc.io/docs/context
49 | */
50 | export const createTRPCContext = async (opts: CreateNextContextOptions) => {
51 | const { req, res } = opts;
52 |
53 | // Get the session from the server using the getServerSession wrapper function
54 | const session = await getServerAuthSession({ req, res });
55 |
56 | return createInnerTRPCContext({
57 | session,
58 | });
59 | };
60 |
61 | /**
62 | * 2. INITIALIZATION
63 | *
64 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
65 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
66 | * errors on the backend.
67 | */
68 | import { initTRPC, TRPCError } from "@trpc/server";
69 | import superjson from "superjson";
70 | import { ZodError } from "zod";
71 |
72 | const t = initTRPC.context().create({
73 | transformer: superjson,
74 | errorFormatter({ shape, error }) {
75 | return {
76 | ...shape,
77 | data: {
78 | ...shape.data,
79 | zodError:
80 | error.cause instanceof ZodError ? error.cause.flatten() : null,
81 | },
82 | };
83 | },
84 | });
85 |
86 | /**
87 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
88 | *
89 | * These are the pieces you use to build your tRPC API. You should import these a lot in the
90 | * "/src/server/api/routers" directory.
91 | */
92 |
93 | /**
94 | * This is how you create new routers and sub-routers in your tRPC API.
95 | *
96 | * @see https://trpc.io/docs/router
97 | */
98 | export const createTRPCRouter = t.router;
99 |
100 | /**
101 | * Public (unauthenticated) procedure
102 | *
103 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
104 | * guarantee that a user querying is authorized, but you can still access user session data if they
105 | * are logged in.
106 | */
107 | export const publicProcedure = t.procedure;
108 |
109 | /** Reusable middleware that enforces users are logged in before running the procedure. */
110 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
111 | if (!ctx.session || !ctx.session.user) {
112 | throw new TRPCError({ code: "UNAUTHORIZED" });
113 | }
114 | return next({
115 | ctx: {
116 | // infers the `session` as non-nullable
117 | session: { ...ctx.session, user: ctx.session.user },
118 | },
119 | });
120 | });
121 |
122 | /**
123 | * Protected (authenticated) procedure
124 | *
125 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
126 | * the session is valid and guarantees `ctx.session.user` is not null.
127 | *
128 | * @see https://trpc.io/docs/procedures
129 | */
130 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
131 |
--------------------------------------------------------------------------------
/src/server/auth.ts:
--------------------------------------------------------------------------------
1 | import { type GetServerSidePropsContext } from "next";
2 | import {
3 | getServerSession,
4 | type NextAuthOptions,
5 | type DefaultSession,
6 | } from "next-auth";
7 | import GoogleProvider from "next-auth/providers/google";
8 | import { PrismaAdapter } from "@next-auth/prisma-adapter";
9 | import { env } from "~/env.mjs";
10 | import { prisma } from "~/server/db";
11 |
12 | /**
13 | * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
14 | * object and keep type safety.
15 | *
16 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
17 | */
18 | declare module "next-auth" {
19 | interface Session extends DefaultSession {
20 | user: {
21 | id: string;
22 | // ...other properties
23 | // role: UserRole;
24 | } & DefaultSession["user"];
25 | }
26 |
27 | // interface User {
28 | // // ...other properties
29 | // // role: UserRole;
30 | // }
31 | }
32 |
33 | /**
34 | * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
35 | *
36 | * @see https://next-auth.js.org/configuration/options
37 | */
38 | export const authOptions: NextAuthOptions = {
39 | callbacks: {
40 | session: ({ session, user }) => ({
41 | ...session,
42 | user: {
43 | ...session.user,
44 | id: user.id,
45 | },
46 | }),
47 | },
48 | adapter: PrismaAdapter(prisma),
49 | providers: [
50 | GoogleProvider({
51 | clientId: env.GOOGLE_CLIENT_ID,
52 | clientSecret: env.GOOGLE_CLIENT_SECRET,
53 | }),
54 | /**
55 | * ...add more providers here.
56 | *
57 | * Most other providers require a bit more work than the Discord provider. For example, the
58 | * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
59 | * model. Refer to the NextAuth.js docs for the provider you want to use. Example:
60 | *
61 | * @see https://next-auth.js.org/providers/github
62 | */
63 | ],
64 | };
65 |
66 | /**
67 | * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
68 | *
69 | * @see https://next-auth.js.org/configuration/nextjs
70 | */
71 | export const getServerAuthSession = (ctx: {
72 | req: GetServerSidePropsContext["req"];
73 | res: GetServerSidePropsContext["res"];
74 | }) => {
75 | return getServerSession(ctx.req, ctx.res, authOptions);
76 | };
77 |
--------------------------------------------------------------------------------
/src/server/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | import { env } from "~/env.mjs";
4 |
5 | const globalForPrisma = globalThis as unknown as {
6 | prisma: PrismaClient | undefined;
7 | };
8 |
9 | export const prisma =
10 | globalForPrisma.prisma ??
11 | new PrismaClient({
12 | log:
13 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
14 | });
15 |
16 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
17 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
3 | * contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
4 | *
5 | * We also create a few inference helpers for input and output types.
6 | */
7 | import { httpBatchLink, loggerLink } from "@trpc/client";
8 | import { createTRPCNext } from "@trpc/next";
9 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
10 | import superjson from "superjson";
11 |
12 | import { type AppRouter } from "~/server/api/root";
13 |
14 | const getBaseUrl = () => {
15 | if (typeof window !== "undefined") return ""; // browser should use relative url
16 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
17 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
18 | };
19 |
20 | /** A set of type-safe react-query hooks for your tRPC API. */
21 | export const api = createTRPCNext({
22 | config() {
23 | return {
24 | /**
25 | * Transformer used for data de-serialization from the server.
26 | *
27 | * @see https://trpc.io/docs/data-transformers
28 | */
29 | transformer: superjson,
30 |
31 | /**
32 | * Links used to determine request flow from client to server.
33 | *
34 | * @see https://trpc.io/docs/links
35 | */
36 | links: [
37 | loggerLink({
38 | enabled: (opts) =>
39 | process.env.NODE_ENV === "development" ||
40 | (opts.direction === "down" && opts.result instanceof Error),
41 | }),
42 | httpBatchLink({
43 | url: `${getBaseUrl()}/api/trpc`,
44 | }),
45 | ],
46 | };
47 | },
48 | /**
49 | * Whether tRPC should await queries when server rendering pages.
50 | *
51 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
52 | */
53 | ssr: false,
54 | });
55 |
56 | /**
57 | * Inference helper for inputs.
58 | *
59 | * @example type HelloInput = RouterInputs['example']['hello']
60 | */
61 | export type RouterInputs = inferRouterInputs;
62 |
63 | /**
64 | * Inference helper for outputs.
65 | *
66 | * @example type HelloOutput = RouterOutputs['example']['hello']
67 | */
68 | export type RouterOutputs = inferRouterOutputs;
69 |
--------------------------------------------------------------------------------
/src/utils/getImageUrl.ts:
--------------------------------------------------------------------------------
1 | import { env } from "~/env.mjs";
2 |
3 | export function getImageUrl(id: string) {
4 | return `http://localhost:9000/${env.NEXT_PUBLIC_S3_BUCKET_NAME}/${id}`;
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "checkJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "noUncheckedIndexedAccess": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "~/*": ["./src/*"]
22 | }
23 | },
24 | "include": [
25 | ".eslintrc.cjs",
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | "**/*.cjs",
30 | "**/*.mjs",
31 | "s3.js"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------