├── .eslintrc.json
├── .vscode
└── extensions.json
├── public
├── favicon.ico
├── images
│ ├── mux-logo.png
│ ├── pscale-connect.png
│ ├── pscale-prisma.png
│ └── pscale-string.png
├── fonts
│ ├── CalSans-SemiBold.woff
│ ├── CalSans-SemiBold.woff2
│ ├── stylesheet.css
│ └── demo.html
└── vercel.svg
├── postcss.config.js
├── screenshots
└── github-oauth.png
├── utils
├── webhooks
│ └── mux
│ │ └── types
│ │ ├── index.ts
│ │ └── video
│ │ ├── index.ts
│ │ └── asset
│ │ ├── index.ts
│ │ ├── created.ts
│ │ └── ready.ts
├── formatDuration.ts
├── db.ts
└── prisma.ts
├── styles
├── globals.css
└── Home.module.css
├── components
├── forms
│ ├── Field.tsx
│ ├── Label.tsx
│ ├── SubmitInput.tsx
│ ├── TextInput.tsx
│ ├── Checkbox.tsx
│ ├── TextAreaInput.tsx
│ ├── LessonForm.tsx
│ └── CourseForm.tsx
├── Banner.tsx
├── EmptyState.tsx
├── layout.tsx
├── CourseGrid.tsx
├── Button.tsx
├── Heading.tsx
├── CourseCard.tsx
├── CourseOverview.tsx
├── Footer.tsx
├── Nav.tsx
└── CourseViewer.tsx
├── next.config.js
├── types
├── next-auth.d.ts
├── environment.d.ts
└── next.d.ts
├── .gitignore
├── pages
├── api
│ ├── auth
│ │ └── [...nextauth].ts
│ ├── lessons
│ │ ├── [id]
│ │ │ ├── complete.ts
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── courses.ts
│ ├── courses
│ │ └── [id].ts
│ └── webhooks
│ │ └── mux.ts
├── _app.tsx
├── admin
│ ├── courses
│ │ ├── new.tsx
│ │ └── [courseId]
│ │ │ ├── lessons
│ │ │ ├── [lessonId].tsx
│ │ │ └── new.tsx
│ │ │ └── index.tsx
│ └── index.tsx
├── index.tsx
└── courses
│ └── [...slug].tsx
├── tsconfig.json
├── .env.example
├── package.json
├── tailwind.config.js
├── prisma
└── schema.prisma
└── README.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "prisma.prisma"
4 | ]
5 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/images/mux-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/images/mux-logo.png
--------------------------------------------------------------------------------
/screenshots/github-oauth.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/screenshots/github-oauth.png
--------------------------------------------------------------------------------
/utils/webhooks/mux/types/index.ts:
--------------------------------------------------------------------------------
1 | import video from "./video"
2 |
3 | const dict = { video }
4 |
5 | export default dict;
--------------------------------------------------------------------------------
/utils/webhooks/mux/types/video/index.ts:
--------------------------------------------------------------------------------
1 | import asset from "./asset"
2 |
3 | const dict = { asset }
4 |
5 | export default dict;
--------------------------------------------------------------------------------
/public/images/pscale-connect.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/images/pscale-connect.png
--------------------------------------------------------------------------------
/public/images/pscale-prisma.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/images/pscale-prisma.png
--------------------------------------------------------------------------------
/public/images/pscale-string.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/images/pscale-string.png
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import url("/fonts/stylesheet.css");
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
--------------------------------------------------------------------------------
/utils/formatDuration.ts:
--------------------------------------------------------------------------------
1 | const fmtMSS = (s: number) => (s - (s %= 60)) / 60 + (9 < s ? ":" : ":0") + s;
2 | export default fmtMSS
3 |
--------------------------------------------------------------------------------
/public/fonts/CalSans-SemiBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/fonts/CalSans-SemiBold.woff
--------------------------------------------------------------------------------
/public/fonts/CalSans-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/video-course-starter-kit/HEAD/public/fonts/CalSans-SemiBold.woff2
--------------------------------------------------------------------------------
/utils/webhooks/mux/types/video/asset/index.ts:
--------------------------------------------------------------------------------
1 | import created from "./created"
2 | import ready from "./ready"
3 |
4 | const dict = { created, ready }
5 |
6 | export default dict;
--------------------------------------------------------------------------------
/utils/db.ts:
--------------------------------------------------------------------------------
1 | import { connect } from '@planetscale/database'
2 |
3 | const config = {
4 | host: process.env.DATABASE_URL,
5 | }
6 |
7 | const conn = connect(config)
8 |
9 | export default conn;
--------------------------------------------------------------------------------
/components/forms/Field.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Field = ({ children }: { children: React.ReactNode }) => (
4 |
5 | {children}
6 |
7 | )
8 |
9 | export default Field;
--------------------------------------------------------------------------------
/public/fonts/stylesheet.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Cal Sans";
3 | src: url("CalSans-SemiBold.woff2") format("woff2"),
4 | url("CalSans-SemiBold.woff") format("woff");
5 | font-weight: 600;
6 | font-style: normal;
7 | font-display: swap;
8 | }
9 |
--------------------------------------------------------------------------------
/components/forms/Label.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | htmlFor: string;
3 | children: React.ReactNode;
4 | }
5 |
6 | const Label = ({ htmlFor, children }: Props) => (
7 | {children}
8 | )
9 |
10 | export default Label;
--------------------------------------------------------------------------------
/components/Banner.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | children: React.ReactNode;
3 | };
4 |
5 | const Banner = ({ children }: Props) => {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default Banner;
14 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | images: {
6 | remotePatterns: [
7 | {
8 | protocol: "https",
9 | hostname: "image.mux.com",
10 | },
11 | ],
12 | },
13 | };
14 |
15 | module.exports = nextConfig;
16 |
--------------------------------------------------------------------------------
/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | children: React.ReactNode;
3 | };
4 |
5 | const Banner = ({ children }: Props) => {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default Banner;
14 |
--------------------------------------------------------------------------------
/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from './Footer'
2 | import Nav from './Nav'
3 |
4 | export default function Layout({ children }: { children: React.ReactNode }) {
5 | return (
6 | <>
7 |
8 |
9 | {children}
10 |
11 |
12 | >
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import NextAuth, { DefaultSession } from "next-auth"
2 |
3 | declare module "next-auth" {
4 | /**
5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
6 | */
7 | interface Session {
8 | user: {
9 | /** Add the user's id */
10 | id: string
11 | } & DefaultSession["user"]
12 | }
13 | }
--------------------------------------------------------------------------------
/utils/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 |
3 | declare global {
4 | // allow global `var` declarations
5 | // eslint-disable-next-line no-var
6 | var prisma: PrismaClient | undefined
7 | }
8 |
9 | export const prisma =
10 | global.prisma ||
11 | new PrismaClient({
12 | log: ['query'],
13 | })
14 |
15 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma
--------------------------------------------------------------------------------
/types/environment.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface ProcessEnv {
3 | DATABASE_URL: string;
4 | GITHUB_ID: string;
5 | GITHUB_SECRET: string;
6 | MUX_TOKEN_ID: string;
7 | MUX_TOKEN_SECRET: string;
8 | MUX_WEBHOOK_SECRET: string;
9 | NEXTAUTH_URL: string;
10 | NEXTAUTH_SECRET: string;
11 | NODE_ENV: 'development' | 'production';
12 | PORT?: string;
13 | PWD: string;
14 | }
15 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/types/next.d.ts:
--------------------------------------------------------------------------------
1 | import type { NextComponentType, NextPageContext } from "next"
2 | import type { Session } from "next-auth"
3 | import type { Router } from "next/router"
4 |
5 | declare module "next/app" {
6 | type AppProps> = {
7 | Component: NextComponentType
8 | router: Router
9 | __N_SSG?: boolean
10 | __N_SSP?: boolean
11 | pageProps: P & {
12 | /** Initial session passed in from `getServerSideProps` or `getInitialProps` */
13 | session?: Session
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/components/forms/SubmitInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 |
3 | type Props = {
4 | value: string;
5 | isLoading: boolean;
6 | }
7 |
8 | const SubmitInput = ({ value, isLoading }: Props) => {
9 | const classes = clsx(
10 | 'inline-block text-white rounded px-4 py-3 w-fit',
11 | !isLoading && 'bg-slate-700 hover:bg-slate-800 cursor-pointer',
12 | isLoading && 'bg-slate-400',
13 | );
14 |
15 | const label = isLoading ? 'Loading...' : value;
16 |
17 | return (
18 |
19 | )
20 | }
21 |
22 | export default SubmitInput;
--------------------------------------------------------------------------------
/components/CourseGrid.tsx:
--------------------------------------------------------------------------------
1 | import CourseCard from 'components/CourseCard'
2 | import type { Course, Lesson, Video } from "@prisma/client"
3 |
4 | type Props = {
5 | isAdmin?: boolean;
6 | courses: (Course & {
7 | lessons: (Lesson & {
8 | video: Video | null;
9 | })[];
10 | })[]
11 | }
12 |
13 | const CourseGrid = ({ courses, isAdmin = false }: Props) => {
14 | return (
15 | <>
16 |
17 | {courses.map(course => (
18 |
19 | ))}
20 |
21 | >
22 | );
23 | };
24 |
25 | export default CourseGrid;
--------------------------------------------------------------------------------
/components/forms/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import { useFormContext, RegisterOptions } from "react-hook-form";
2 | import Field from "./Field"
3 | import Label from "./Label"
4 |
5 | type Props = {
6 | name: string;
7 | label: string;
8 | options: RegisterOptions;
9 | }
10 |
11 | const TextInput = ({ name, label, options }: Props) => {
12 | const { register, formState: { errors } } = useFormContext();
13 |
14 | return (
15 |
16 | {label}
17 |
18 | {errors[name] && {name} is required }
19 |
20 | )
21 | }
22 |
23 | export default TextInput;
--------------------------------------------------------------------------------
/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 |
3 | type Props = {
4 | children: React.ReactNode;
5 | onClick?: () => void;
6 | rest?: any;
7 | intent?: "primary" | "secondary" | "danger";
8 | };
9 |
10 | const Button = ({ children, intent = 'primary', ...rest }: Props) => {
11 | return (
12 |
21 | {children}
22 |
23 | );
24 | };
25 |
26 | export default Button;
--------------------------------------------------------------------------------
/components/forms/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { useFormContext, RegisterOptions } from "react-hook-form";
2 | import Field from "./Field"
3 | import Label from "./Label"
4 |
5 | type Props = {
6 | name: string;
7 | label: string;
8 | options?: RegisterOptions;
9 | }
10 |
11 | const Checkbox = ({ name, label, options = {} }: Props) => {
12 | const { register, formState: { errors } } = useFormContext();
13 |
14 | return (
15 |
16 |
17 |
18 | {label}
19 |
20 |
21 | )
22 | }
23 |
24 | export default Checkbox;
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth"
2 | import type { NextAuthOptions } from 'next-auth'
3 | import GithubProvider from "next-auth/providers/github"
4 |
5 | import { PrismaAdapter } from "@next-auth/prisma-adapter"
6 | import { prisma } from "utils/prisma"
7 |
8 | export const authOptions: NextAuthOptions = {
9 | adapter: PrismaAdapter(prisma),
10 | providers: [
11 | GithubProvider({
12 | clientId: process.env.GITHUB_ID,
13 | clientSecret: process.env.GITHUB_SECRET,
14 | })
15 | ],
16 | callbacks: {
17 | async session({ session, token, user }) {
18 | if (session.user) {
19 | session.user.id = user.id
20 | }
21 |
22 | return session
23 | }
24 | }
25 | }
26 |
27 | export default NextAuth(authOptions)
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"],
22 | "pages/*": ["./pages/*"],
23 | "components/*": ["./components/*"],
24 | "utils/*": ["./utils/*"]
25 | }
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
--------------------------------------------------------------------------------
/utils/webhooks/mux/types/video/asset/created.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 |
3 | type PlaybackId = {
4 | id: string;
5 | policy: | 'signed' | 'public'
6 | }
7 |
8 | type Props = {
9 | data: { [key: string]: any }
10 | metadata: { userId: string }
11 | }
12 |
13 | const handler = async ({ data, metadata }: Props) => {
14 | const { upload_id, playback_ids, status } = data;
15 |
16 | await prisma.video.updateMany({
17 | where: {
18 | uploadId: upload_id,
19 | status: { not: 'ready' }
20 | },
21 | data: {
22 | publicPlaybackId: playback_ids.find((row: PlaybackId) => row.policy === 'public').id,
23 | privatePlaybackId: playback_ids.find((row: PlaybackId) => row.policy === 'signed').id,
24 | status,
25 | }
26 | });
27 | }
28 |
29 | export default handler;
--------------------------------------------------------------------------------
/components/forms/TextAreaInput.tsx:
--------------------------------------------------------------------------------
1 | import { useFormContext, RegisterOptions } from "react-hook-form";
2 | import Field from "./Field"
3 | import Label from "./Label"
4 | import TextareaAutosize from 'react-textarea-autosize';
5 |
6 | type Props = {
7 | name: string;
8 | label: string;
9 | options: RegisterOptions;
10 | }
11 |
12 | const TextAreaInput = ({ name, label, options }: Props) => {
13 | const { register, formState: { errors } } = useFormContext();
14 |
15 | return (
16 |
17 | {label}
18 |
19 | {errors[name] && {name} is required }
20 |
21 | )
22 | }
23 |
24 | export default TextAreaInput;
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Check the README for a guided example on retrieving this value for production
2 | DATABASE_URL="mysql://root@127.0.0.1:3309/video-course-starter-kit"
3 |
4 | # Easily generate a new value for the NextAuth secret here
5 | # https://generate-secret.vercel.app/
6 | NEXTAUTH_SECRET=
7 |
8 | # Create a new GitHub OAuth app to get these values
9 | # https://github.com/settings/developers
10 | GITHUB_ID=
11 | GITHUB_SECRET=
12 |
13 | # Learn how to get your Mux access tokens
14 | # https://docs.mux.com/guides/video/make-api-requests
15 | MUX_TOKEN_ID=
16 | MUX_TOKEN_SECRET=
17 |
18 | # Optionally verify incoming webhooks are from Mux
19 | # https://docs.mux.com/guides/video/verify-webhook-signatures
20 | MUX_WEBHOOK_SECRET=
21 |
22 | # This is only required for local development – you don't have to set this when deploying to Vercel
23 | NEXTAUTH_URL="http://localhost:3000"
24 |
--------------------------------------------------------------------------------
/utils/webhooks/mux/types/video/asset/ready.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 |
3 | type PlaybackId = {
4 | id: string;
5 | policy: | 'signed' | 'public'
6 | }
7 |
8 | type Props = {
9 | data: { [key: string]: any }
10 | metadata: { userId: string }
11 | }
12 |
13 | const handler = async ({ data, metadata }: Props) => {
14 | const { upload_id, playback_ids, duration, status, aspect_ratio } = data;
15 |
16 | // update video record
17 | await prisma.video.update({
18 | where: {
19 | uploadId: upload_id
20 | },
21 | data: {
22 | publicPlaybackId: playback_ids.find((row: PlaybackId) => row.policy === 'public').id,
23 | privatePlaybackId: playback_ids.find((row: PlaybackId) => row.policy === 'signed').id,
24 | duration,
25 | aspectRatio: aspect_ratio,
26 | status,
27 | }
28 | });
29 | }
30 |
31 | export default handler;
--------------------------------------------------------------------------------
/components/Heading.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | children: React.ReactNode;
3 | as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
4 | };
5 |
6 | const Heading = ({ children, as = 'h1' }: Props) => {
7 | switch (as) {
8 | case 'h1':
9 | return {children} ;
10 | case 'h2':
11 | return {children} ;
12 | case 'h3':
13 | return {children} ;
14 | case 'h4':
15 | return {children} ;
16 | case 'h5':
17 | return {children} ;
18 | case 'h6':
19 | return {children} ;
20 | }
21 | };
22 |
23 | export default Heading;
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/components/forms/LessonForm.tsx:
--------------------------------------------------------------------------------
1 | import { FormProvider, useForm, SubmitHandler } from "react-hook-form";
2 | import TextInput from 'components/forms/TextInput';
3 | import TextAreaInput from 'components/forms/TextAreaInput';
4 | import SubmitInput from 'components/forms/SubmitInput';
5 | import { Lesson } from "@prisma/client";
6 |
7 | export type Inputs = {
8 | name: string;
9 | description: string;
10 | };
11 |
12 | type Props = {
13 | lesson?: Lesson;
14 | onSubmit: SubmitHandler;
15 | isLoading: boolean;
16 | }
17 |
18 | const LessonForm = ({ lesson, onSubmit, isLoading }: Props) => {
19 | const methods = useForm({ defaultValues: { name: lesson?.name, description: lesson?.description } });
20 |
21 | return (
22 |
23 |
28 |
29 | )
30 | }
31 |
32 | export default LessonForm;
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/globals.css'
2 | import type { AppProps } from 'next/app'
3 | import type { ReactElement, ReactNode } from 'react'
4 | import type { NextPage } from 'next'
5 | import { SessionProvider } from "next-auth/react"
6 | import Layout from 'components/layout'
7 | import { Toaster } from 'react-hot-toast'
8 |
9 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
10 |
11 | const queryClient = new QueryClient()
12 |
13 | export type NextPageWithLayout = NextPage
& {
14 | getLayout?: (page: ReactElement) => ReactNode
15 | }
16 |
17 | type AppPropsWithLayout = AppProps & {
18 | Component: NextPageWithLayout
19 | }
20 |
21 | function MyApp({
22 | Component,
23 | pageProps: { session, ...pageProps }
24 | }: AppPropsWithLayout) {
25 | // Use the layout defined at the page level, if available
26 | const getLayout = Component.getLayout ?? ((page) => {page} )
27 |
28 | return (
29 |
30 |
31 | {getLayout( )}
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | export default MyApp
39 |
--------------------------------------------------------------------------------
/pages/admin/courses/new.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import { SubmitHandler } from "react-hook-form";
3 | import { useRouter } from 'next/router'
4 | import { useMutation } from '@tanstack/react-query'
5 | import toast from 'react-hot-toast';
6 |
7 | import Heading from 'components/Heading';
8 | import CourseForm, { Inputs } from 'components/forms/CourseForm';
9 |
10 | type CourseCreateResult = {
11 | id: number;
12 | }
13 |
14 | const AdminNewCourse: NextPage = () => {
15 | const router = useRouter()
16 | const handler = (newCourse: Inputs) => {
17 | return fetch('/api/courses', {
18 | method: 'POST', body: JSON.stringify(newCourse)
19 | }).then(res => res.json())
20 | }
21 |
22 | const mutation = useMutation({
23 | mutationFn: handler,
24 | onSuccess: (data: CourseCreateResult) => {
25 | router.push(`/admin/courses/${data.id}`)
26 | },
27 | onError: (error) => {
28 | console.error(error)
29 | toast.error('Something went wrong')
30 | }
31 | })
32 |
33 | const onSubmit: SubmitHandler = async data => {
34 | mutation.mutate(data);
35 | };
36 |
37 | return (
38 | <>
39 | New course
40 |
41 | >
42 | );
43 |
44 | }
45 |
46 | export default AdminNewCourse
47 |
--------------------------------------------------------------------------------
/pages/api/lessons/[id]/complete.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 | import type { UserLessonProgress } from '@prisma/client'
4 | import { getServerSession } from "next-auth/next"
5 | import { authOptions } from "../../auth/[...nextauth]"
6 |
7 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
8 | const { method } = req
9 | const { id: lessonId } = req.query
10 | if (typeof lessonId !== "string") { throw new Error('missing id') };
11 |
12 | switch (method) {
13 | case 'POST':
14 | const session = await getServerSession(req, res, authOptions)
15 | if (!session) res.status(401).end();
16 |
17 | try {
18 | const userId = session?.user?.id
19 | if (!userId) throw Error("Cannot create course: missing id on user record")
20 |
21 | const connect = await prisma.userLessonProgress.create({
22 | data: {
23 | lessonId: parseInt(lessonId),
24 | userId
25 | }
26 | })
27 |
28 | res.status(200).json(connect)
29 | } catch (e) {
30 | console.error('Request error', e)
31 | res.status(500).end();
32 | }
33 | break;
34 | default:
35 | res.setHeader('Allow', ['POST'])
36 | res.status(405).end(`Method ${method} Not Allowed`)
37 | break
38 | }
39 | }
--------------------------------------------------------------------------------
/components/CourseCard.tsx:
--------------------------------------------------------------------------------
1 | import { Course, Lesson, Video } from "@prisma/client";
2 | import Link from 'next/link'
3 | import Image from 'next/image'
4 | import Heading from './Heading'
5 |
6 | type Props = {
7 | isAdmin: boolean;
8 | course: Course & {
9 | lessons: (Lesson & {
10 | video: Video | null;
11 | })[];
12 | }
13 | }
14 |
15 | const CourseCard = ({ course, isAdmin }: Props) => {
16 | const href = isAdmin ? `/admin/courses/${course.id}` : `/courses/${course.id}`
17 | return (
18 | <>
19 |
20 | {course.lessons[0]?.video?.publicPlaybackId && (
21 |
28 | )}
29 |
30 |
31 | {!course.published && (
Draft )}
32 |
33 |
34 | {course.name}
35 |
36 |
37 | {course.description}
38 |
39 |
40 |
41 | >
42 | );
43 | };
44 |
45 | export default CourseCard;
--------------------------------------------------------------------------------
/components/forms/CourseForm.tsx:
--------------------------------------------------------------------------------
1 | import { FormProvider, useForm, SubmitHandler } from "react-hook-form";
2 | import TextInput from './TextInput';
3 | import TextAreaInput from './TextAreaInput';
4 | import SubmitInput from './SubmitInput';
5 | import Checkbox from "./Checkbox";
6 | import { Course } from "@prisma/client";
7 |
8 | export type Inputs = {
9 | name: string;
10 | description: string;
11 | };
12 |
13 | type Props = {
14 | course?: Course;
15 | onSubmit: SubmitHandler;
16 | isLoading: boolean;
17 | }
18 |
19 | const CourseForm = ({ course, onSubmit, isLoading }: Props) => {
20 | const methods = useForm({ defaultValues: { name: course?.name, description: course?.description } });
21 |
22 | return (
23 |
24 |
35 |
36 | )
37 | }
38 |
39 | export default CourseForm;
--------------------------------------------------------------------------------
/components/CourseOverview.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import type { Course, Lesson, Video } from "@prisma/client"
3 | import Heading from 'components/Heading'
4 | import ReactMarkdown from 'react-markdown'
5 |
6 | type Props = {
7 | course: (Course & {
8 | lessons: (Lesson & {
9 | video: Video | null;
10 | })[];
11 | })
12 | }
13 |
14 | const CourseOverview = ({ course }: Props) => {
15 | return (
16 | <>
17 |
18 |
{course.name}
19 |
20 |
21 |
22 |
23 | {course.description}
24 |
25 |
26 |
27 |
What you'll learn
28 | {course.lessons.map(lesson => (
29 |
30 | {lesson.video?.publicPlaybackId && (
31 |
37 | )}
38 |
39 |
{lesson.name}
40 |
{lesson.description}
41 |
42 |
43 | ))}
44 |
45 | >
46 | );
47 | };
48 |
49 | export default CourseOverview;
50 |
51 |
52 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | const Footer = () => (
2 |
38 | )
39 |
40 | export default Footer;
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage, GetStaticProps, GetServerSideProps } from 'next'
2 | import type { Course, Lesson, Video } from "@prisma/client"
3 | import { prisma } from 'utils/prisma'
4 | import { authOptions } from 'pages/api/auth/[...nextauth]'
5 | import { getServerSession } from "next-auth/next"
6 | import Heading from 'components/Heading'
7 | import CourseGrid from 'components/CourseGrid'
8 |
9 | type HomePageProps = {
10 | courses: (Course & {
11 | lessons: (Lesson & {
12 | video: Video | null;
13 | })[];
14 | })[]
15 | }
16 |
17 | const Home: NextPage = ({ courses }) => {
18 | return (
19 | <>
20 | {courses.length > 0 ? (View these video courses ) : (There are no courses to view )}
21 | {courses.find(course => course.published === false) && (
22 | Draft courses are only visible to you
23 | )}
24 |
25 | >
26 | )
27 | }
28 |
29 | export default Home
30 |
31 | export const getServerSideProps: GetServerSideProps = async (context) => {
32 | const session = await getServerSession(context.req, context.res, authOptions)
33 |
34 | const courses = await prisma.course.findMany({
35 | where: {
36 | OR: [
37 | {
38 | published: true
39 | },
40 | {
41 | author: {
42 | id: session?.user?.id
43 | }
44 | },
45 | ],
46 | },
47 | include: { lessons: { include: { video: true } } }
48 | })
49 |
50 | return {
51 | props: {
52 | courses
53 | },
54 | }
55 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "video-course-starter-kit",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "npx prisma generate && next dev",
7 | "build": "npx prisma generate && npx prisma db push && next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:proxy": "pscale connect video-course-starter-kit main --port 3309"
11 | },
12 | "dependencies": {
13 | "@mux/blurhash": "^0.1.2",
14 | "@mux/mux-node": "^8.0.0",
15 | "@mux/mux-player-react": "^2.3.2",
16 | "@mux/mux-uploader-react": "^1.0.0-beta.15",
17 | "@next-auth/prisma-adapter": "^1.0.7",
18 | "@planetscale/database": "^1.15.0",
19 | "@prisma/client": "5.9.1",
20 | "@sindresorhus/slugify": "^2.2.1",
21 | "@tailwindcss/forms": "^0.5.7",
22 | "@tailwindcss/typography": "^0.5.10",
23 | "@tanstack/react-query": "^5.18.1",
24 | "clsx": "^2.1.0",
25 | "lodash.get": "^4.4.2",
26 | "next": "14.1.0",
27 | "next-auth": "^4.24.5",
28 | "prisma": "^5.9.1",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "react-hook-form": "^7.50.1",
32 | "react-hot-toast": "^2.4.1",
33 | "react-markdown": "^9.0.1",
34 | "react-textarea-autosize": "^8.5.3"
35 | },
36 | "devDependencies": {
37 | "@types/lodash.get": "^4.4.9",
38 | "@types/node": "18.19.14",
39 | "@types/react": "18.2.55",
40 | "@types/react-dom": "18.2.19",
41 | "autoprefixer": "^10.4.17",
42 | "eslint": "8.56.0",
43 | "eslint-config-next": "14.1.0",
44 | "postcss": "^8.4.35",
45 | "tailwindcss": "^3.4.1",
46 | "typescript": "5.3.3"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/pages/api/courses.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 | import type { Course } from '@prisma/client'
4 | import { getServerSession } from "next-auth/next"
5 | import { authOptions } from "./auth/[...nextauth]"
6 | import slugify from '@sindresorhus/slugify';
7 |
8 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
9 | const { method } = req
10 | const session = await getServerSession(req, res, authOptions)
11 | if (!session) res.status(401).end();
12 |
13 | console.log("Session", JSON.stringify(session, null, 2))
14 |
15 | switch (method) {
16 | case 'GET':
17 | try {
18 | const courses = await prisma.course.findMany()
19 | res.status(200).json(courses)
20 | } catch (e) {
21 | console.error('Request error', e)
22 | res.status(500).end();
23 | }
24 | break
25 | case 'POST':
26 | const { name, description } = JSON.parse(req.body)
27 |
28 | try {
29 | const email = session?.user?.email
30 | if (!email) throw Error("Cannot create course: missing email on user record")
31 |
32 | const course = await prisma.course.create({
33 | data: {
34 | name,
35 | description,
36 | slug: slugify(name),
37 | author: {
38 | connect: {
39 | email
40 | }
41 | }
42 | }
43 | })
44 | res.status(200).json(course)
45 | } catch (e) {
46 | console.error('Request error', e)
47 | res.status(500).end();
48 | }
49 | break
50 | default:
51 | res.setHeader('Allow', ['GET'])
52 | res.status(405).end(`Method ${method} Not Allowed`)
53 | break
54 | }
55 | }
--------------------------------------------------------------------------------
/pages/api/lessons/[id]/index.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 | import type { Lesson } from '@prisma/client'
4 | import { getServerSession } from "next-auth/next"
5 | import { authOptions } from "../../auth/[...nextauth]"
6 |
7 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
8 | const { method } = req
9 | const { id: lessonId } = req.query
10 | if (typeof lessonId !== "string") { throw new Error('missing id') };
11 |
12 | const session = await getServerSession(req, res, authOptions)
13 | if (!session) res.status(401).end();
14 |
15 | console.log("Session", JSON.stringify(session, null, 2))
16 | const id = session?.user?.id
17 | if (!id) throw Error("Cannot create course: missing id on user record")
18 |
19 | const checkLesson = await prisma.lesson.findFirst({
20 | where: { id: parseInt(lessonId) },
21 | include: { course: true }
22 | })
23 |
24 | if (checkLesson?.course?.authorId !== id) {
25 | res.status(401).end();
26 | return;
27 | }
28 |
29 | switch (method) {
30 | case 'PUT':
31 | const { name, description } = JSON.parse(req.body)
32 |
33 | try {
34 | const lesson = await prisma.lesson.update({
35 | where: { id: parseInt(lessonId) },
36 | data: {
37 | name,
38 | description,
39 | }
40 | })
41 | res.status(200).json(lesson)
42 |
43 | } catch (e) {
44 | console.error('Request error', e)
45 | res.status(500).end();
46 | }
47 | break;
48 | case 'DELETE':
49 | try {
50 | await prisma.lesson.delete({ where: { id: parseInt(lessonId) } })
51 | res.status(201).end();
52 | } catch (e) {
53 | console.error('Request error', e)
54 | res.status(500).end();
55 | }
56 | break
57 | default:
58 | res.setHeader('Allow', ['PUT, DELETE'])
59 | res.status(405).end(`Method ${method} Not Allowed`)
60 | break
61 | }
62 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 |
3 | const defaultTheme = require("tailwindcss/defaultTheme");
4 |
5 | module.exports = {
6 | content: [
7 | "./pages/**/*.{js,ts,jsx,tsx}",
8 | "./components/**/*.{js,ts,jsx,tsx}",
9 | "./app/**/*.{js,ts,jsx,tsx}",
10 | ],
11 | theme: {
12 | fontFamily: {
13 | cal: ["Cal Sans", "Inter var", "sans-serif"],
14 | },
15 | extend: {
16 | colors: {
17 | current: "currentColor",
18 | },
19 | width: {
20 | 1536: "1536px",
21 | },
22 | height: {
23 | 150: "37.5rem",
24 | },
25 | margin: {
26 | 30: "7.5rem",
27 | },
28 | fontFamily: {
29 | sans: ["Inter var", ...defaultTheme.fontFamily.sans],
30 | mono: ["Consolas", ...defaultTheme.fontFamily.mono],
31 | },
32 | typography: {
33 | DEFAULT: {
34 | css: {
35 | h1: {
36 | fontFamily: "Cal Sans",
37 | },
38 | h2: {
39 | fontFamily: "Cal Sans",
40 | },
41 | h3: {
42 | fontFamily: "Cal Sans",
43 | },
44 | "blockquote p:first-of-type::before": { content: "none" },
45 | "blockquote p:first-of-type::after": { content: "none" },
46 | },
47 | },
48 | },
49 | keyframes: {
50 | wiggle: {
51 | "0%, 100%": {
52 | transform: "translateX(0%)",
53 | transformOrigin: "50% 50%",
54 | },
55 | "15%": { transform: "translateX(-6px) rotate(-6deg)" },
56 | "30%": { transform: "translateX(9px) rotate(6deg)" },
57 | "45%": { transform: "translateX(-9px) rotate(-3.6deg)" },
58 | "60%": { transform: "translateX(3px) rotate(2.4deg)" },
59 | "75%": { transform: "translateX(-2px) rotate(-1.2deg)" },
60 | },
61 | },
62 | animation: {
63 | wiggle: "wiggle 0.8s both",
64 | },
65 | },
66 | },
67 | plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
68 | };
69 |
--------------------------------------------------------------------------------
/pages/api/courses/[id].ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 | import type { Course } from '@prisma/client'
4 | import { getServerSession } from "next-auth/next"
5 | import { authOptions } from "../auth/[...nextauth]"
6 |
7 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
8 | const { method } = req
9 | const { id: courseId } = req.query
10 | if (typeof courseId !== "string") { throw new Error('missing id') };
11 |
12 | switch (method) {
13 | case 'GET':
14 | try {
15 | const course: Course | null = await prisma.course.findUnique({ where: { id: parseInt(courseId) } })
16 | res.status(200).json(course)
17 | } catch (e) {
18 | console.error('Request error', e)
19 | res.status(500).end();
20 | }
21 | break
22 | case 'PUT':
23 | const { name, description } = JSON.parse(req.body)
24 | const session = await getServerSession(req, res, authOptions)
25 | if (!session) res.status(401).end();
26 |
27 | try {
28 | const id = session?.user?.id
29 | if (!id) throw Error("Cannot create course: missing id on user record")
30 |
31 | const [course] = await prisma.course.findMany({
32 | where: {
33 | id: parseInt(courseId),
34 | author: {
35 | id: {
36 | equals: id
37 | }
38 | }
39 | },
40 | })
41 |
42 | if (!course) {
43 | res.status(401).end();
44 | }
45 |
46 | const updateCourse = await prisma.course.update({
47 | where: {
48 | id: parseInt(courseId)
49 | },
50 | data: {
51 | name: name,
52 | description: description
53 | },
54 | })
55 |
56 | res.status(200).json(updateCourse)
57 |
58 | } catch (e) {
59 | console.error('Request error', e)
60 | res.status(500).end();
61 | }
62 | break;
63 | default:
64 | res.setHeader('Allow', ['GET', 'PUT'])
65 | res.status(405).end(`Method ${method} Not Allowed`)
66 | break
67 | }
68 | }
--------------------------------------------------------------------------------
/pages/admin/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import { prisma } from 'utils/prisma'
3 | import { useSession } from "next-auth/react"
4 | import { GetServerSideProps } from 'next'
5 | import { authOptions } from 'pages/api/auth/[...nextauth]'
6 | import { getServerSession } from "next-auth/next"
7 | import type { Session } from 'next-auth'
8 | import type { Course, Lesson, Video } from '@prisma/client'
9 | import Link from 'next/link'
10 | import CourseGrid from 'components/CourseGrid'
11 | import Button from 'components/Button'
12 |
13 | import Heading from 'components/Heading'
14 |
15 | type AdminIndexPageProps = {
16 | session: Session;
17 | courses: (Course & {
18 | lessons: (Lesson & {
19 | video: Video | null;
20 | })[];
21 | })[]
22 | }
23 |
24 | const AdminIndex: NextPage = ({ courses }) => {
25 | const { data: session } = useSession()
26 |
27 | if (session) {
28 | return (
29 | <>
30 | Admin
31 | Your courses
32 |
33 | {courses.length > 0 ? (
34 |
35 | ) : (
36 |
37 | You don't have any courses yet.
38 |
39 | )}
40 |
41 |
42 | Create a course
43 |
44 | >
45 | )
46 | }
47 | return Access Denied
48 | }
49 |
50 | export default AdminIndex
51 |
52 | export const getServerSideProps: GetServerSideProps = async (context) => {
53 | const session = await getServerSession(context.req, context.res, authOptions)
54 |
55 | if (!session) {
56 | return {
57 | redirect: {
58 | destination: '/',
59 | permanent: false,
60 | },
61 | }
62 | }
63 |
64 | const courses = await prisma.course.findMany({
65 | where: {
66 | author: {
67 | id: session.user?.id
68 | }
69 | },
70 | include: {
71 | lessons: {
72 | include: {
73 | video: true
74 | }
75 | }
76 | }
77 | })
78 |
79 | return {
80 | props: {
81 | session,
82 | courses
83 | },
84 | }
85 | }
--------------------------------------------------------------------------------
/pages/api/lessons/index.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from 'utils/prisma'
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 | import type { Course, Lesson } from '@prisma/client'
4 | import { getServerSession } from "next-auth/next"
5 | import { authOptions } from "../auth/[...nextauth]"
6 | import slugify from '@sindresorhus/slugify';
7 |
8 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
9 | const { method } = req
10 | const session = await getServerSession(req, res, authOptions)
11 | if (!session) res.status(401).end();
12 |
13 | console.log("Session", JSON.stringify(session, null, 2))
14 |
15 | switch (method) {
16 | case 'POST':
17 | const { name, description, courseId, uploadId } = JSON.parse(req.body)
18 |
19 | try {
20 | const id = session?.user?.id
21 | if (!id) throw Error("Cannot create course: missing id on user record")
22 |
23 | const [course] = await prisma.course.findMany({
24 | where: {
25 | id: parseInt(courseId),
26 | author: {
27 | id: {
28 | equals: id
29 | }
30 | }
31 | },
32 | })
33 |
34 | if (!course) {
35 | res.status(401).end();
36 | }
37 |
38 | const [video] = await prisma.video.findMany({
39 | where: {
40 | uploadId,
41 | owner: {
42 | id: {
43 | equals: id
44 | }
45 | }
46 | }
47 | })
48 |
49 | if (!video) {
50 | res.status(401).end();
51 | }
52 |
53 | const lesson = await prisma.lesson.create({
54 | data: {
55 | name,
56 | description,
57 | slug: slugify(name),
58 | course: {
59 | connect: {
60 | id: course.id
61 | }
62 | },
63 | video: {
64 | connect: {
65 | uploadId
66 | }
67 | }
68 | }
69 | })
70 |
71 | res.status(200).json(lesson)
72 | } catch (e) {
73 | console.error('Request error', e)
74 | res.status(500).end();
75 | }
76 | break
77 | default:
78 | res.setHeader('Allow', ['POST'])
79 | res.status(405).end(`Method ${method} Not Allowed`)
80 | break
81 | }
82 | }
--------------------------------------------------------------------------------
/components/Nav.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import Link from 'next/link'
3 | import Image from "next/image"
4 | import { useSession, signIn, signOut } from "next-auth/react"
5 |
6 | const Nav = () => {
7 | const { data: session } = useSession()
8 |
9 | return (
10 | <>
11 |
12 | Video course starter kit – powered by Mux
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {session && (
27 |
28 |
29 | Admin
30 |
31 |
32 | )}
33 |
34 |
35 |
36 |
42 |
43 | Video Course Starter Kit
44 |
45 |
46 |
47 |
48 | {session ? (
49 |
50 | Signed in as {session.user?.email}
51 | signOut()}>Sign out
52 |
53 | ) : (
54 |
55 | signIn()}>Sign in with GitHub
56 |
57 | )}
58 |
59 |
60 | >
61 | )
62 | }
63 |
64 | export default Nav;
65 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .title {
43 | margin: 0;
44 | line-height: 1.15;
45 | font-size: 4rem;
46 | }
47 |
48 | .title,
49 | .description {
50 | text-align: center;
51 | }
52 |
53 | .description {
54 | margin: 4rem 0;
55 | line-height: 1.5;
56 | font-size: 1.5rem;
57 | }
58 |
59 | .code {
60 | background: #fafafa;
61 | border-radius: 5px;
62 | padding: 0.75rem;
63 | font-size: 1.1rem;
64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
65 | Bitstream Vera Sans Mono, Courier New, monospace;
66 | }
67 |
68 | .grid {
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-wrap: wrap;
73 | max-width: 800px;
74 | }
75 |
76 | .card {
77 | margin: 1rem;
78 | padding: 1.5rem;
79 | text-align: left;
80 | color: inherit;
81 | text-decoration: none;
82 | border: 1px solid #eaeaea;
83 | border-radius: 10px;
84 | transition: color 0.15s ease, border-color 0.15s ease;
85 | max-width: 300px;
86 | }
87 |
88 | .card:hover,
89 | .card:focus,
90 | .card:active {
91 | color: #0070f3;
92 | border-color: #0070f3;
93 | }
94 |
95 | .card h2 {
96 | margin: 0 0 1rem 0;
97 | font-size: 1.5rem;
98 | }
99 |
100 | .card p {
101 | margin: 0;
102 | font-size: 1.25rem;
103 | line-height: 1.5;
104 | }
105 |
106 | .logo {
107 | height: 1em;
108 | margin-left: 0.5rem;
109 | }
110 |
111 | @media (max-width: 600px) {
112 | .grid {
113 | width: 100%;
114 | flex-direction: column;
115 | }
116 | }
117 |
118 | @media (prefers-color-scheme: dark) {
119 | .card,
120 | .footer {
121 | border-color: #222;
122 | }
123 | .code {
124 | background: #111;
125 | }
126 | .logo img {
127 | filter: invert(1);
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/pages/api/webhooks/mux.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 | import type { Readable } from 'node:stream';
3 |
4 | import Mux from '@mux/mux-node';
5 |
6 | import WEBHOOK_TYPES from "../../../utils/webhooks/mux/types"
7 | import get from "lodash.get"
8 |
9 | const webhookSecret = process.env.MUX_WEBHOOK_SECRET;
10 | const mux = new Mux();
11 |
12 | export const config = {
13 | api: {
14 | bodyParser: false,
15 | },
16 | };
17 |
18 | async function buffer(readable: Readable) {
19 | const chunks = [];
20 | for await (const chunk of readable) {
21 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
22 | }
23 | return Buffer.concat(chunks);
24 | }
25 |
26 | function verifyWebhookSignature(rawBody: string, req: NextApiRequest) {
27 | if (webhookSecret) {
28 | // this will raise an error if signature is not valid
29 | mux.webhooks.verifySignature(rawBody, req.headers, webhookSecret);
30 | } else {
31 | console.log('Skipping webhook signature verification because no secret is configured'); // eslint-disable-line no-console
32 | }
33 | return true;
34 | };
35 |
36 | export default async function assetHandler(req: NextApiRequest, res: NextApiResponse) {
37 | const { method } = req
38 |
39 | switch (method) {
40 | case 'POST':
41 | // First, attempt to verify the webhook
42 | const rawBody = await buffer(req).then(buf => buf.toString('utf8'));
43 |
44 | try {
45 | verifyWebhookSignature(rawBody, req);
46 | } catch (e) {
47 | console.error('Error verifyWebhookSignature - is the correct signature secret set?', e);
48 | res.status(400).end((e as Error).message)
49 | return;
50 | }
51 |
52 | const jsonBody = JSON.parse(rawBody);
53 | const { data, type } = jsonBody;
54 |
55 | const WEBHOOK_TYPE_HANDLER = get(WEBHOOK_TYPES, type);
56 |
57 | if (WEBHOOK_TYPE_HANDLER) {
58 | try {
59 | const passthrough = data.passthrough || data.new_asset_settings.passthrough;
60 | const metadata = JSON.parse(passthrough);
61 |
62 | await WEBHOOK_TYPE_HANDLER({ data, metadata });
63 | res.status(200).end();
64 | return;
65 | } catch (err) {
66 | if (err instanceof Error) {
67 | console.log(`Webhook Error: ${err.message}`);
68 | return res.status(400).send(`Webhook Error: ${err.message}`);
69 | }
70 |
71 | console.error('Request error', err)
72 | res.status(500).end();
73 | return;
74 | }
75 | } else {
76 | res.status(200).end();
77 | return;
78 | }
79 |
80 | default:
81 | res.setHeader('Allow', ['POST'])
82 | res.status(405).end(`Method ${method} Not Allowed`)
83 | break
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/pages/courses/[...slug].tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react'
2 | import { useState } from 'react'
3 | import type { GetServerSideProps } from 'next'
4 | import type { Course, Lesson, Video } from "@prisma/client"
5 | import muxBlurHash from "@mux/blurhash";
6 | import { prisma } from 'utils/prisma'
7 | import { authOptions } from 'pages/api/auth/[...nextauth]'
8 | import { getServerSession } from "next-auth/next"
9 | import { useSession } from "next-auth/react"
10 | import Link from 'next/link'
11 | import type { NextPageWithLayout } from 'pages/_app'
12 | import CourseViewer from 'components/CourseViewer'
13 | import Nav from 'components/Nav'
14 | import Banner from 'components/Banner'
15 |
16 | type VideoWithPlaceholder = Video & { placeholder?: string }
17 |
18 | type ViewCoursePageProps = {
19 | course: (Course & {
20 | lessons: (Lesson & {
21 | video: VideoWithPlaceholder | null;
22 | })[];
23 | });
24 | completedLessons: number[];
25 | }
26 |
27 | const ViewCourse: NextPageWithLayout = ({ course, completedLessons }) => {
28 | const { data: session } = useSession()
29 | const [lessonProgress, setLessonProgress] = useState(completedLessons)
30 |
31 | return (
32 | <>
33 | {!session && (
34 |
35 |
36 |
37 | Sign in
38 | to track your progress →{' '}
39 |
40 |
41 | )}
42 |
43 | >
44 | )
45 | }
46 |
47 | ViewCourse.getLayout = function getLayout(page: ReactElement) {
48 | return (
49 | <>
50 |
51 | {page}
52 | >
53 | )
54 | }
55 |
56 | export default ViewCourse
57 |
58 | export const getServerSideProps: GetServerSideProps = async (context) => {
59 | const session = await getServerSession(context.req, context.res, authOptions)
60 |
61 | const id = context?.query?.slug?.[0]
62 | if (typeof id !== "string") { throw new Error('missing id') };
63 |
64 | const course = await prisma.course.findUnique({
65 | where: { id: parseInt(id) },
66 | include: {
67 | lessons: {
68 | include: {
69 | video: true
70 | }
71 | }
72 | },
73 | })
74 |
75 | if (!course) {
76 | return { notFound: true }
77 | }
78 |
79 | if (course.published === false && course.authorId !== session?.user?.id) {
80 | return { notFound: true }
81 | }
82 |
83 | const completedLessons = await prisma.userLessonProgress.findMany({
84 | where: {
85 | userId: session?.user?.id,
86 | lessonId: {
87 | in: course.lessons.map(lesson => lesson.id)
88 | }
89 | }
90 | }).then(progress => progress.map(p => p.lessonId))
91 |
92 | course.lessons = await Promise.all(course.lessons.map(async (lesson) => {
93 | if (lesson?.video?.publicPlaybackId) {
94 | const { blurHashBase64 } = await muxBlurHash(lesson.video.publicPlaybackId);
95 | (lesson.video as VideoWithPlaceholder).placeholder = blurHashBase64;
96 | }
97 | return lesson
98 | }))
99 |
100 | return {
101 | props: {
102 | session,
103 | course,
104 | completedLessons
105 | },
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "mysql"
7 | url = env("DATABASE_URL")
8 | relationMode = "prisma"
9 | }
10 |
11 | model Account {
12 | id String @id @default(cuid())
13 | userId String
14 | type String
15 | provider String
16 | providerAccountId String
17 | refresh_token String? @db.Text
18 | access_token String? @db.Text
19 | expires_at Int?
20 | token_type String?
21 | scope String?
22 | id_token String? @db.Text
23 | session_state String?
24 |
25 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
26 |
27 | @@unique([provider, providerAccountId])
28 | }
29 |
30 | model Session {
31 | id String @id @default(cuid())
32 | sessionToken String @unique
33 | userId String
34 | expires DateTime
35 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
36 | }
37 |
38 | model User {
39 | id String @id @default(cuid())
40 | name String?
41 | email String? @unique
42 | emailVerified DateTime?
43 | image String?
44 | accounts Account[]
45 | sessions Session[]
46 | courses Course[]
47 | videos Video[]
48 | completedLessons UserLessonProgress[]
49 | }
50 |
51 | model VerificationToken {
52 | identifier String
53 | token String @unique
54 | expires DateTime
55 |
56 | @@unique([identifier, token])
57 | }
58 |
59 | model Course {
60 | id Int @id @default(autoincrement())
61 | name String
62 | description String
63 | slug String
64 | lessons Lesson[]
65 | author User @relation(fields: [authorId], references: [id])
66 | authorId String
67 | published Boolean @default(false)
68 | }
69 |
70 | model Lesson {
71 | id Int @id @default(autoincrement())
72 | name String
73 | description String
74 | slug String
75 | course Course @relation(fields: [courseId], references: [id])
76 | courseId Int
77 | video Video?
78 | usersCompleted UserLessonProgress[]
79 | }
80 |
81 | model Video {
82 | id Int @id @default(autoincrement())
83 | lesson Lesson? @relation(fields: [lessonId], references: [id])
84 | lessonId Int? @unique
85 | owner User @relation(fields: [ownerId], references: [id])
86 | ownerId String
87 | uploadId String @unique
88 | publicPlaybackId String?
89 | privatePlaybackId String?
90 | duration Float?
91 | aspectRatio String?
92 | status String @default("preparing")
93 | posterTime Float?
94 | }
95 |
96 | model UserLessonProgress {
97 | user User @relation(fields: [userId], references: [id])
98 | userId String // relation scalar field (used in the `@relation` attribute above)
99 | lesson Lesson @relation(fields: [lessonId], references: [id])
100 | lessonId Int // relation scalar field (used in the `@relation` attribute above)
101 | completedAt DateTime @default(now())
102 |
103 | @@id([userId, lessonId])
104 | }
105 |
--------------------------------------------------------------------------------
/pages/admin/courses/[courseId]/lessons/[lessonId].tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage, GetServerSideProps } from 'next'
2 | import { prisma } from 'utils/prisma'
3 | import { useSession } from "next-auth/react"
4 | import { authOptions } from 'pages/api/auth/[...nextauth]'
5 | import { getServerSession } from "next-auth/next"
6 | import type { Session } from 'next-auth'
7 | import type { Lesson, Video } from '@prisma/client'
8 | import { useRouter } from 'next/router'
9 | import { SubmitHandler } from "react-hook-form";
10 | import MuxPlayer from "@mux/mux-player-react/lazy";
11 | import LessonForm, { Inputs } from 'components/forms/LessonForm'
12 | import Button from 'components/Button'
13 | import toast from 'react-hot-toast';
14 | import { useMutation } from '@tanstack/react-query'
15 |
16 | type AdminLessonEditPageProps = {
17 | session: Session;
18 | lesson: Lesson & {
19 | video: Video | null;
20 | }
21 | }
22 |
23 | const AdminLessonEdit: NextPage = ({ lesson }) => {
24 | const { data: session } = useSession()
25 | const router = useRouter()
26 |
27 | const updateLesson = (data: Inputs) => {
28 | return fetch(`/api/lessons/${lesson.id}`, {
29 | method: 'PUT', body: JSON.stringify(data)
30 | }).then(res => res.json())
31 | }
32 |
33 | const deleteLesson = () => {
34 | return fetch(`/api/lessons/${lesson.id}`, { method: 'DELETE' })
35 | }
36 |
37 | const updateMutation = useMutation({
38 | mutationFn: updateLesson,
39 | onSuccess: () => {
40 | toast.success('Lesson updated successfully')
41 | },
42 | onError: (error) => {
43 | console.error(error)
44 | toast.error('Something went wrong')
45 | }
46 | })
47 |
48 | const deleteMutation = useMutation({
49 | mutationFn: deleteLesson,
50 | onSuccess: () => {
51 | router.push(`/admin/courses/${lesson.courseId}`)
52 | toast.success('Lesson deleted successfully')
53 | },
54 | onError: (error) => {
55 | console.error(error)
56 | toast.error('Something went wrong')
57 | }
58 | })
59 |
60 | const onSubmit: SubmitHandler = async data => {
61 | updateMutation.mutate(data);
62 | };
63 |
64 | if (session) {
65 | return (
66 |
67 |
68 | {lesson.video?.status === "ready" && lesson.video.publicPlaybackId ? (
69 |
79 | ) : (
80 |
81 | )}
82 |
83 |
Delete this lesson
84 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 | return Access Denied
92 | }
93 |
94 | export default AdminLessonEdit
95 |
96 | export const getServerSideProps: GetServerSideProps = async (context) => {
97 | const session = await getServerSession(context.req, context.res, authOptions)
98 |
99 | if (!session) {
100 | return {
101 | redirect: {
102 | destination: '/',
103 | permanent: false,
104 | },
105 | }
106 | }
107 |
108 | const id = context?.params?.lessonId
109 | if (typeof id !== "string") { throw new Error('missing id') };
110 |
111 | const [lesson] = await prisma.lesson.findMany({
112 | where: {
113 | id: parseInt(id),
114 | course: {
115 | author: {
116 | id: session.user?.id
117 | }
118 | }
119 | },
120 | include: {
121 | video: true
122 | }
123 | })
124 |
125 | if (!lesson) {
126 | return {
127 | notFound: true
128 | }
129 | }
130 |
131 | return {
132 | props: {
133 | session,
134 | lesson
135 | },
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/pages/admin/courses/[courseId]/lessons/new.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import type { NextPage, GetServerSideProps } from 'next'
3 | import { useForm, SubmitHandler, FormProvider } from "react-hook-form";
4 | import { useRouter } from 'next/router'
5 | import { prisma } from 'utils/prisma'
6 |
7 | import Mux from '@mux/mux-node';
8 | const mux = new Mux();
9 |
10 | import MuxUploader from '@mux/mux-uploader-react';
11 | import { getServerSession } from "next-auth/next"
12 | import { authOptions } from 'pages/api/auth/[...nextauth]'
13 | import type { Session } from 'next-auth'
14 | import { useMutation } from '@tanstack/react-query';
15 | import toast from 'react-hot-toast';
16 |
17 | import Heading from 'components/Heading';
18 | import TextInput from 'components/forms/TextInput';
19 | import TextAreaInput from 'components/forms/TextAreaInput';
20 | import Field from 'components/forms/Field';
21 | import SubmitInput from 'components/forms/SubmitInput';
22 |
23 | type Inputs = {
24 | name: string;
25 | description: string;
26 | uploadId: string;
27 | courseId: string;
28 | };
29 |
30 | type AdminNewLessonPageProps = {
31 | session: Session;
32 | uploadUrl: string;
33 | uploadId: string;
34 | }
35 |
36 | type LessonCreateResult = {
37 | id: number;
38 | }
39 |
40 | const AdminNewLesson: NextPage = ({ uploadUrl, uploadId }) => {
41 | const router = useRouter()
42 | const courseId = router.query.courseId as string
43 | const [isVideoUploaded, setIsVideoUploaded] = useState(false)
44 |
45 | const methods = useForm();
46 |
47 | const handler = (data: Inputs) => {
48 | return fetch('/api/lessons', {
49 | method: 'POST', body: JSON.stringify(data)
50 | }).then(res => res.json())
51 | }
52 |
53 | const mutation = useMutation({
54 | mutationFn: handler,
55 | onSuccess: (data: LessonCreateResult) => {
56 | router.push(`/admin/courses/${courseId}/lessons/${data.id}`)
57 | },
58 | onError: (error) => {
59 | console.error(error)
60 | toast.error('Something went wrong')
61 | }
62 | })
63 |
64 | const onSubmit: SubmitHandler = async data => {
65 | mutation.mutate(data);
66 | };
67 |
68 | return (
69 | <>
70 | New lesson
71 |
72 |
96 |
97 | >
98 | );
99 | }
100 |
101 | export default AdminNewLesson
102 |
103 | export const getServerSideProps: GetServerSideProps = async (context) => {
104 | const session = await getServerSession(context.req, context.res, authOptions)
105 |
106 | if (!session) {
107 | return {
108 | redirect: {
109 | destination: '/',
110 | permanent: false,
111 | },
112 | }
113 | }
114 |
115 | const upload = await mux.video.uploads.create({
116 | cors_origin: 'https://localhost:3000',
117 | new_asset_settings: {
118 | playback_policy: ['public', 'signed'],
119 | passthrough: JSON.stringify({ userId: session.user?.id })
120 | }
121 | });
122 |
123 | await prisma.video.create({
124 | data: {
125 | uploadId: upload.id,
126 | owner: {
127 | connect: { id: session.user.id }
128 | }
129 | }
130 | });
131 |
132 | return {
133 | props: {
134 | session,
135 | uploadId: upload.id,
136 | uploadUrl: upload.url
137 | },
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/pages/admin/courses/[courseId]/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import { prisma } from 'utils/prisma'
3 | import { useSession } from "next-auth/react"
4 | import { GetServerSideProps } from 'next'
5 | import { authOptions } from 'pages/api/auth/[...nextauth]'
6 | import { getServerSession } from "next-auth/next"
7 | import type { Session } from 'next-auth'
8 | import type { Course, Lesson, Video } from '@prisma/client'
9 | import Link from 'next/link'
10 | import Image from 'next/image'
11 | import CourseForm, { Inputs } from 'components/forms/CourseForm';
12 | import { SubmitHandler } from "react-hook-form";
13 | import Heading from 'components/Heading';
14 | import Button from 'components/Button';
15 | import toast from 'react-hot-toast';
16 | import { useMutation } from '@tanstack/react-query'
17 |
18 | type AdminCourseEditPageProps = {
19 | session: Session;
20 | course: Course & {
21 | lessons: (Lesson & {
22 | video: Video | null;
23 | })[];
24 | }
25 | }
26 |
27 | type CourseUpdateResult = {
28 | id: number;
29 | }
30 |
31 | const AdminCourseEdit: NextPage = ({ course }) => {
32 | const { data: session } = useSession()
33 |
34 | const handler = (data: Inputs) => {
35 | return fetch(`/api/courses/${course.id}`, {
36 | method: 'PUT', body: JSON.stringify(data)
37 | }).then(res => res.json())
38 | }
39 |
40 | const mutation = useMutation({
41 | mutationFn: handler,
42 | onSuccess: (data: CourseUpdateResult) => {
43 | toast.success('Course updated successfully')
44 | },
45 | onError: (error) => {
46 | console.error(error)
47 | toast.error('Something went wrong')
48 | }
49 | })
50 |
51 | const onSubmit: SubmitHandler = async data => {
52 | mutation.mutate(data);
53 | };
54 |
55 | if (session) {
56 | return (
57 |
58 |
59 | {course.name}
60 |
61 |
62 |
63 |
64 |
Lessons
65 | {course.lessons.length > 0 ? (
66 | <>
67 | {
68 | course.lessons.map(lesson => (
69 |
70 | {lesson.video?.publicPlaybackId && (
71 |
77 | )}
78 |
79 |
80 | {lesson.name}
81 |
82 |
83 | ))
84 | }
85 | >
86 | ) : (
87 |
88 |
None yet.
89 |
90 | )}
91 |
92 |
93 |
Add a lesson
94 |
95 |
96 |
97 | )
98 | }
99 | return Access Denied
100 | }
101 |
102 | export default AdminCourseEdit
103 |
104 | export const getServerSideProps: GetServerSideProps = async (context) => {
105 | const session = await getServerSession(context.req, context.res, authOptions)
106 |
107 | if (!session) {
108 | return {
109 | redirect: {
110 | destination: '/',
111 | permanent: false,
112 | },
113 | }
114 | }
115 |
116 | const id = context?.params?.courseId
117 | if (typeof id !== "string") { throw new Error('missing id') };
118 |
119 | const [course] = await prisma.course.findMany({
120 | where: {
121 | id: parseInt(id),
122 | author: {
123 | email: session.user?.email
124 | }
125 | },
126 | include: {
127 | lessons: {
128 | include: {
129 | video: true
130 | }
131 | }
132 | },
133 | })
134 |
135 | if (!course) {
136 | return {
137 | notFound: true
138 | }
139 | }
140 |
141 | return {
142 | props: {
143 | session,
144 | course
145 | },
146 | }
147 | }
--------------------------------------------------------------------------------
/components/CourseViewer.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useRouter } from 'next/router'
3 | import Image from 'next/image'
4 | import type { Course, Lesson, Video } from "@prisma/client"
5 | import Heading from 'components/Heading'
6 | import EmptyState from 'components/EmptyState'
7 | import MuxPlayer from "@mux/mux-player-react/lazy";
8 | import formatDuration from 'utils/formatDuration'
9 | import clsx from 'clsx';
10 | import type { UserLessonProgress } from '@prisma/client'
11 |
12 | type Props = {
13 | course: (Course & {
14 | lessons: (Lesson & {
15 | video: (Video & { placeholder?: string }) | null;
16 | })[];
17 | });
18 | lessonProgress: number[];
19 | setLessonProgress: (lessonProgess: number[]) => void;
20 | }
21 |
22 | const CourseViewer = ({ course, lessonProgress = [], setLessonProgress }: Props) => {
23 | const router = useRouter()
24 | const slug = (router.query.slug as string[]) || []
25 | const lessonIndex = slug[2] ? parseInt(slug[2]) - 1 : 0
26 |
27 | const [activeLesson, setActiveLesson] = useState(course.lessons[lessonIndex]);
28 | const playbackId = activeLesson?.video?.publicPlaybackId
29 | const videoReady = activeLesson?.video?.status === "ready"
30 | const placeholder = activeLesson?.video?.placeholder
31 |
32 | useEffect(() => {
33 | const lessonIndex = course.lessons.findIndex(lesson => lesson.id === activeLesson.id) + 1
34 | router.push(`/courses/${course.id}/lessons/${lessonIndex}`, undefined, { shallow: true })
35 | // eslint-disable-next-line react-hooks/exhaustive-deps
36 | }, [activeLesson, course])
37 |
38 | const markLessonCompleted = async () => {
39 | try {
40 | const result: UserLessonProgress = await fetch(`/api/lessons/${activeLesson.id}/complete`, {
41 | method: 'POST'
42 | }).then(res => res.json())
43 | setLessonProgress([...lessonProgress, result.lessonId])
44 | } catch (error) {
45 | console.log('Something went wrong')
46 | }
47 | }
48 |
49 | if (!course.lessons.length) {
50 | return (
51 |
52 |
53 | This course does not have any lessons
54 |
55 |
56 | );
57 | }
58 |
59 | return (
60 |
61 |
62 | {playbackId && videoReady ? (
63 |
75 | ) : (
76 |
77 | )}
78 |
79 |
{activeLesson.name}
80 |
{activeLesson.description}
81 |
82 |
83 |
119 |
120 | );
121 | };
122 |
123 | export default CourseViewer;
124 |
125 |
126 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Video Course Starter Kit
2 |
3 | This project demonstrates how you can use Next.js and Mux to build your own video course platform. You might use this repo as a starting point for building your own membership-gated video course platform.
4 |
5 | Feel free to browse the source code to see how you can use Mux's video APIs to upload, encode, and playback videos in your Next.js app.
6 |
7 | Try out our hosted version of the application at [https://video-course-starter-kit.mux.dev](https://video-course-starter-kit.mux.dev)
8 |
9 | ## Stack details
10 |
11 | We used modern tooling to build this example project, including:
12 |
13 | - Written in [TypeScript](https://www.typescriptlang.org/)
14 | - [Tailwind](https://tailwindcss.com/) for CSS styling
15 | - [Planetscale](https://planetscale.com/) for data persistence
16 | - [Prisma](https://www.prisma.io/) for ORM
17 | - [NextAuth](https://next-auth.js.org/) for authentication via GitHub
18 | - [Mux](https://mux.com) for video streaming and thumbnail generation
19 | - [Mux Player](https://docs.mux.com/guides/video/mux-player) for video playback
20 | - [Mux Uploader](https://github.com/muxinc/elements/tree/main/packages/mux-uploader-react) for video uploading
21 |
22 | ## Deploy your own
23 | ### Register for a Mux account
24 |
25 | Mux will encode and serve all of the videos within the video course. To get started with a complimentary $20 in credits, [sign up for a Mux account](https://dashboard.mux.com/signup)
26 |
27 | ### Register for a Planetscale account
28 | Planetscale is a MySQL-compatible serverless database platform. Signing up for Planetscale is free – you can [register your account here](https://auth.planetscale.com/sign-up) if you don't already have one.
29 |
30 | ### Install this repo
31 | Run the follow three commands to clone this repo and install its dependencies:
32 | ```
33 | git clone https://github.com/muxinc/video-course-starter-kit.git
34 | cd video-course-starter-kit
35 | yarn
36 | ```
37 |
38 | ### Create a `.env.local` file
39 |
40 | This project uses several secrets to access the different accounts used throughout the codebase. You can configure these values locally by copying the `.env.local.example` file to a new file called `.env.local` and filling out the values as you receive them in the steps below.
41 |
42 | Also, don't forget to add the values to the project's environment variables in production on Vercel.
43 |
44 | ### Mux account setup
45 |
46 | To authenticate this application with your Mux account, [create a new access token](https://dashboard.mux.com/settings/access-tokens) within the Mux dashboard and copy the access token ID and secret into your `.env.local` file and into your Vercel environment variables.
47 |
48 | ### Database Setup
49 |
50 | First, make sure you have `mysql-client` installed locally so you can take full advantage of the `pscale` CLI tool down the road. On MacOS with Homebrew installed, you can run the following command in your terminal:
51 |
52 | ```
53 | brew install mysql-client
54 | ```
55 |
56 | Next, install the [Planetscale CLI](https://github.com/planetscale/cli). Again, on MacOS, this command will do the trick:
57 |
58 | ```
59 | brew install planetscale/tap/pscale
60 | ```
61 |
62 | Next, authorize the Planetscale CLI with your newly created account by running:
63 |
64 | ```
65 | pscale auth login
66 | ```
67 |
68 | Create a new database in your Planetscale account called `video-course-starter-kit`
69 | ```
70 | pscale database create video-course-starter-kit
71 | ```
72 | Follow the link provided to you as a result of running this command to get the connection string for your database.
73 |
74 | 
75 | 
76 | 
77 |
78 | Copy the resulting authenticated database url value into your `.env.local` file and into your Vercel environment variables.
79 |
80 | We'll connect to this database locally by opening a connection to it on a local port. Here's how you can connect to the Planetscale database `video-course-starter-kit` on port 3309:
81 |
82 | ```
83 | pscale connect video-course-starter-kit main --port 3309
84 | ```
85 |
86 | ## Modifying the database schema
87 |
88 | If you'd like to make any changes to the database schema, you can do so by following these steps:
89 |
90 | ```
91 | pscale branch create video-course-starter-kit my-new-branch
92 |
93 | # after a few moments, close and reopen db proxy to the new branch
94 | pscale connect video-course-starter-kit my-new-branch --port 3309
95 |
96 | # change your schema in the prisma/schema.prisma file... then,
97 | npx prisma generate
98 | npx prisma db push
99 |
100 | # when ready, make a deploy request
101 | pscale deploy-request create video-course-starter-kit my-new-branch
102 |
103 | # shipit
104 | pscale deploy-request deploy video-course-starter-kit 1
105 | ```
106 |
107 | ## Inspecting the database
108 | Prisma provides a nice interface to be able to load up your database contents and see the data that is powering your application. When you've connected to your Planetscale database, you can load up the Prisma GUI with the following command:
109 |
110 | ```
111 | npx prisma studio
112 | ```
113 |
114 | ## Handling webhooks
115 |
116 | Mux uses webhooks to communicate the status of your uploaded video assets back to your application. To handle these webhooks locally, you'll first need to install [ngrok](https://ngrok.com/download).
117 |
118 | ```shell
119 | brew install ngrok/ngrok/ngrok
120 | ngrok config add-authtoken
121 | ngrok http 3000
122 | ```
123 |
124 | Now, we need to make Mux aware of your ngrok URL. Visit [https://dashboard.mux.com/settings/webhooks](https://dashboard.mux.com/settings/webhooks) and add the tunnel URL listed in your terminal as a URL that Mux should notify with new events.
125 |
126 | > Make sure to append `/api/webhooks/mux` to the end of your tunnel URL.
127 |
128 | Then, copy the Webhook signing secret value and paste it into your `.env.local` file under `MUX_WEBHOOK_SECRET`
129 |
130 | ### Run the development server
131 |
132 | Starting up the dev server is a simple one line command:
133 |
134 | ```
135 | yarn dev
136 | ```
137 |
138 | ### GitHub OAuth setup
139 |
140 | End users of this video course application can authenticate with their GitHub account. As a prerequisite, you'll need to create an OAuth App on GitHub that associates a user's access to your application with your GitHub account.
141 |
142 | To create your OAuth app, follow these steps:
143 |
144 | 1. Go to [https://github.com/settings/developers](https://github.com/settings/developers)
145 |
146 | 2. Click "OAuth Apps" and create an Oauth application to use in Development:
147 |
148 | 
149 |
150 | | Application name | Homepage URL | Authorization callback URL |
151 | |--------------------------------|----------------------------------------------------|----------------------------|
152 | | Video Course Starter Kit (dev) | https://github.com/muxinc/video-course-starter-kit | http://localhost:3000/ |
153 |
154 | 3. Copy the `GITHUB_ID` and `GITHUB_SECRET` and paste them into your environment variables on Vercel and in your `.env.local` file.
155 |
156 | > Note: when you deploy a production copy of this application, you'll need to create another GitHub OAuth app which uses your production URL as the "Authorization callback URL" value.
157 | ## Recommended VS code extensions
158 | ### Prisma
159 | The [Prisma extension](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) adds syntax highlighting, formatting, auto-completion, jump-to-definition and linting for .prisma files.
160 |
161 | ## Questions? Comments?
162 |
163 | Tweet us [@MuxHQ](https://twitter.com/muxhq) or email help@mux.com with anything you need help with.
--------------------------------------------------------------------------------
/public/fonts/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Transfonter demo
10 |
11 |
247 |
248 |
249 |
250 |
251 |
254 | Cal Sans SemiBold
255 |
256 |
257 | .your-style {
258 | font-family: 'Cal Sans';
259 | font-weight: 600;
260 | font-style: normal;
261 | }
263 |
264 | <link rel="preload" href="CalSans-SemiBold.woff2" as="font" type="font/woff2" crossorigin>
266 |
270 |
271 | abcdefghijklmnopqrstuvwxyz
272 | ABCDEFGHIJKLMNOPQRSTUVWXYZ
273 | 0123456789.:,;()*!?'@#<>$%&^+-=~
274 |
275 |
276 | The quick brown fox jumps over the lazy dog.
277 |
278 |
279 | The quick brown fox jumps over the lazy dog.
280 |
281 |
282 | The quick brown fox jumps over the lazy dog.
283 |
284 |
285 | The quick brown fox jumps over the lazy dog.
286 |
287 |
288 | The quick brown fox jumps over the lazy dog.
289 |
290 |
291 | The quick brown fox jumps over the lazy dog.
292 |
293 |
294 | The quick brown fox jumps over the lazy dog.
295 |
296 |
297 | The quick brown fox jumps over the lazy dog.
298 |
299 |
300 | The quick brown fox jumps over the lazy dog.
301 |
302 |
303 | The quick brown fox jumps over the lazy dog.
304 |
305 |
306 | The quick brown fox jumps over the lazy dog.
307 |
308 |
309 |
310 |
311 |
312 |
313 |
--------------------------------------------------------------------------------