Repository: Apestein/nextflix Branch: main Commit: 0eb4be5f1bd8 Files: 81 Total size: 126.4 KB Directory structure: gitextract_w94r5yd9/ ├── .eslintrc.cjs ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── README.md ├── components.json ├── drizzle.config.ts ├── next.config.mjs ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── src/ │ ├── actions/ │ │ ├── index.ts │ │ └── safe-action-client.ts │ ├── app/ │ │ ├── (auth)/ │ │ │ ├── layout.tsx │ │ │ ├── sign-in/ │ │ │ │ └── [[...sign-in]]/ │ │ │ │ └── page.tsx │ │ │ └── sign-up/ │ │ │ └── [[...sign-up]]/ │ │ │ └── page.tsx │ │ ├── (main)/ │ │ │ ├── @modal/ │ │ │ │ ├── (.)show/ │ │ │ │ │ └── [id]/ │ │ │ │ │ ├── modal.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── default.tsx │ │ │ │ └── loading.tsx │ │ │ ├── account/ │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── default.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── movies/ │ │ │ │ └── page.tsx │ │ │ ├── my-list/ │ │ │ │ ├── infinite-scroller.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── new-and-popular/ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── search/ │ │ │ │ ├── loading.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── search-input.tsx │ │ │ ├── show/ │ │ │ │ └── [id]/ │ │ │ │ └── page.tsx │ │ │ ├── subscription/ │ │ │ │ ├── loading.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── plan-selector.tsx │ │ │ │ └── result/ │ │ │ │ └── page.tsx │ │ │ └── tv-shows/ │ │ │ └── page.tsx │ │ ├── (profile)/ │ │ │ ├── loading.tsx │ │ │ ├── manage-profile/ │ │ │ │ ├── [...slug]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── add/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── switch-profile/ │ │ │ ├── page.tsx │ │ │ └── profile-switcher.tsx │ │ ├── api/ │ │ │ └── (webhook)/ │ │ │ └── stripe/ │ │ │ └── route.ts │ │ ├── error.tsx │ │ ├── layout.tsx │ │ └── not-found.tsx │ ├── components/ │ │ ├── link-button.tsx │ │ ├── modal-card.tsx │ │ ├── overlay-scrollbar.tsx │ │ ├── show-bg.tsx │ │ ├── show-carousel.tsx │ │ ├── show-hero.tsx │ │ ├── theme-provider.tsx │ │ └── ui/ │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── scroll-area.tsx │ │ ├── separator.tsx │ │ ├── skeleton.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts │ ├── db/ │ │ ├── client.ts │ │ ├── migrate.ts │ │ └── schema.ts │ ├── env.mjs │ ├── lib/ │ │ ├── client-fetchers.ts │ │ ├── configs.ts │ │ ├── globals.css │ │ ├── server-fetchers.ts │ │ ├── stripe.ts │ │ ├── types.ts │ │ └── utils.ts │ └── middleware.ts ├── tailwind.config.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.cjs ================================================ /** @type {import("eslint").Linter.Config} */ const config = { parser: "@typescript-eslint/parser", parserOptions: { project: true, }, plugins: ["@typescript-eslint"], extends: [ "next/core-web-vitals", "plugin:@typescript-eslint/recommended-type-checked", "plugin:@typescript-eslint/stylistic-type-checked", ], rules: { // These opinionated rules are enabled in stylistic-type-checked above. // Feel free to reconfigure them to your own preference. "@typescript-eslint/array-type": "off", "@typescript-eslint/consistent-type-definitions": "off", "@typescript-eslint/consistent-type-imports": [ "warn", { prefer: "type-imports", fixStyle: "inline-type-imports", }, ], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], }, } module.exports = config ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] env: DATABASE_URL: "https://fake.com" SKIP_ENV_VALIDATION: true jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Bun uses: oven-sh/setup-bun@v1 - name: Install Dependencies run: bun install - name: Typecheck run: bun run typecheck - name: Lint run: bun run lint - name: Print Environment Variable run: echo $MY_ENV_VAR ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # database /prisma/db.sqlite /prisma/db.sqlite-journal # next.js /.next/ /out/ next-env.d.ts # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files # 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 .env .env*.local # vercel .vercel # typescript *.tsbuildinfo ================================================ FILE: README.md ================================================ ## Project using bleeding-edge stack. Drizzle ORM + Neon postgres + Clerk auth + Shadcn/ui + everything new in Next.js 13 (server components, server actions, streaming ui, parallel routes, intercepting routes). Now fully edge runtime deployed. ### Full Tech Stack - Next.js 13 - CreateT3App Bootrapped - Neon (postgres) - Drizzle ORM - Tailwind + Shadcn/ui - Clerk - Lucide Icons - Zod Validation - Stripe ### Project Description Netflix clone, project inspired by [@sadmann17](https://twitter.com/sadmann17). Bootrapped with CreateT3App. Project uses 100% server actions, zero api endpoints aside for webhooks. [next-safe-action](https://github.com/TheEdoRan/next-safe-action) library for typesafe server actions. Each account can have up to 4 profiles. Each profiles have it's own avatar and list of saved shows. Feature includes ability to search show catalog, SaaS subscription service with Stripe, optimistic update, and infinite scrolling. ### How To Run Locally Clone repo, install dependencies, and set environment variables inside ".env.example", remember to rename ".env.exmaple" to ".env". Run "npm run dev" -> "npm run stripe:listen"(forward stripe events to local). ### Overall Thoughts Next.js 13 app router overall was a joy to work with and is GREATLY superior to page router. I will say the Next.js app router docs are currently very terrible and you will be left to figure out many things on your own. However, the tools that Next.js 13 gives you are very powerful and things you won't find in any other framework. Server components are underrated and more powerful than you may think. There is an art to interweaving server and client components that is hard to grasp until you get down and dirty with them. Parrallel and intercepting routes are incredibly useful although very buggy. Lucky for you, I've already figured out most of the bugs/tricky bits so just read the "Tricky things" section. Streaming ui and suspense is great. It really makes it easy to handle loading states. This is one of the most impactful things about app dir vs page dir. I saved the most important topic for last. When I first started using server actions I really didn't understand the point. They kind of felt like another way to write api endpoints and just felt like a worst version of tRPC. The Next.js docs will push you to use the server component version of server actions using forms but trust me, don't use server actions with forms. If you do, you are giving up the best feature of server actions which is the tRPC like typesafety. To get the best DX out of server actions, I recommend using [next-safe-actions](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/next-safe-action), this lib is a game changer. It made server actions felt just like tRPC and overall was just an amazing DX. I think it's still too early for server actions to replace tRPC but the nice thing is that it requires zero setup. Setting tRPC up for app dir would be a headache right now. Also note, [revalidatePath/Tag currently only work with server actions](https://github.com/pingdotgg/zact) and you will definitely need them. ### Thoughts about Clerk Clerk was amazing to work with in terms of DX. Extremely easy to setup and get rolling. However, there is a major problems that's a deal breakers until they fix it. - Clerk causes your entire app to be dyamically rendered. Meaning you can not benefit from things like SSG and ISR. Override with "cache: force-cache" or "revalidate = 0" is not possible. ![Screenshot (83)](https://github.com/Apestein/nextflix/assets/107362680/6d2d89d0-63f3-4d6c-97a7-3a12f514868e) ### Thoughts about Neon - Error prone, not production ready, support is slow to respond, and hard to get help because they don't have Discord. Wouldn't recommend for serious projects. Foreign key contraint is nice to have compared to Planetscale. The biggest pro is the [data branching](https://planetscale.com/docs/concepts/data-branching) feature is free. On Planetscale you need "Scaler Pro" for this feature. Data branching makes a huge difference for development/debugging. I do miss not having Planetscale's "Slowest queries during the last 24 hours" panel in the dashboard. ### Thoughts about Drizzle Fantastic. Noticeably faster than Prisma. Schema file being in typescript results in superior DX. Their docs are a little lacking though. ### Project Setup CreateT3App comes with some nice things like T3 Env and Typescript-Eslint preconfigured. To bootstrap with CreateT3App, you just need to delete page dir and create app dir. And VERY important, in next.config.mjs you must delete "i18n" property. ### Project Structure Some people like to break everything down into neat little components and organize them into different files. I prefer big files, nothing gets extracted until it gets used in at least 2 different places. If your site is complex you will probably need many different layout. Your root layout.tsx file should contain only the things shared by your entire app. For parts of your site that need different layout use [route groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups). ![Screenshot (74)](https://github.com/Apestein/nextflix/assets/107362680/44bffd04-e537-49ca-a945-1b1185a4b64f) ### Tricky Things To Consider (I will be going over things I found tricky or difficult in this section) #### 1. You will no doubt run into problems with Next.js aggressive caching. To invalidate router cache, you must use [RevalidatePath/Tage in server action](https://nextjs.org/docs/app/building-your-application/caching#invalidation-1). However, Revalidate/Tag also causes the current page to refresh. I don't know why Next.js decided to do this but get around this I use this [LinkButton](https://github.com/Apestein/nextflix/blob/main/src/components/link-button.tsx) component. See the problem below. [scrnli_8_22_2023_12-30-04 PM2.webm](https://github.com/Apestein/nextflix/assets/107362680/da7dd256-0a91-4ce5-99c6-698bc37d8013) #### 2. When a new user creates an account or signs in with Clerk's oauth I needed to create an account and profile in my database. At first, I was using Clerk's webhook to create them but the problem was users would get redirected to the landing page before the webhook could add the account and profile to the database. As a result, when users first creates the account. The UserButton component that displayed their avatar and profile infomation was missing. To get around this, I had a [CustomUserComponent]() check if the user exist in the database or not (if not, add). ```ts async function CustomeUserButton() { const { userId } = auth() if (!userId) return const existingAccount = await getAccountWithActiveProfile() const account = existingAccount ?? (await createAccountAndProfile()) ... } ``` Additionally, you should wrap the component in suspense to not block the UI and prevent unresponsiveness. ```ts }> ``` #### 3. I had an object that I needed to extract a tuple from to validate with zod. [Here is how](https://github.com/Apestein/nextflix/blob/main/src/lib/configs.ts). ```ts export const createCheckoutSession = authAction( z.object({ stripeProductId: z.string(), planName: z.enum(planTuple), }), } ``` ![Screenshot (78)](https://github.com/Apestein/nextflix/assets/107362680/98e2f8f8-3b44-46d7-baa6-abe95d8463fa) #### 4. Infinite scrolling can be tricky to implement yourself. Typically, I would use React Query/SWR to do this but I wanted to implement it with [server actions](https://github.com/Apestein/nextflix/blob/main/src/actions/index.ts) this time. ```ts // actions/index.ts export const getMyShowsInfinite = authAction( z.object({ index: z.number().min(0), limit: z.number().min(2).max(50), }), async (input) => { const account = await getAccountWithActiveProfile() const shows = await db.query.myShows.findMany({ where: eq(myShows.profileId, account.activeProfileId), limit: input.limit + 1, offset: input.index * input.limit, }) const hasNextPage = shows.length > input.limit ? true : false if (hasNextPage) shows.pop() const filteredShows = await getMyShowsFromTmdb(shows) return { shows: filteredShows, hasNextPage } }, ) ``` Then, I use this modified [infinite scroll component](https://github.com/Apestein/better-react-infinite-scroll) that I created. See the implementation [here](), ignore the stuff about simulated shows. Important thing to understand is inside IntersectionObserver callback function, you must use refs instead of state. That is because of scoping, the callback is only created once and all the variables inside are snapshotted. To get around this you need to use refs. There maybe other ways, I'm just listing what I know. ```ts const observer = new IntersectionObserver((entries) => { if (!hasNextPageRef.current) return // <= must use ref, don't use state }) ``` [scrnli_8_22_2023_12-42-31 PM3.webm](https://github.com/Apestein/nextflix/assets/107362680/e9ceae54-1ea0-4c89-97c7-0d87d12bd135) #### 5. For Stripe intergration. Reference these 2 repos and mine also of course. Be careful with webhooks, use the Stripe CLI to forward events to your local environment when testing. - [Official Next.js example using server actions](https://github.com/vercel/next.js/tree/canary/examples/with-stripe-typescript) - [Taxonomy](https://github.com/shadcn-ui/taxonomy) #### 6. Optimistic update with server actions can be tricky. Using next-safe-action's useOptimisticAction hook helps here. [Here is how I did it](https://github.com/Apestein/nextflix/blob/main/src/components/modal-card.tsx). [scrnli_8_22_2023_12-17-36 PM.webm](https://github.com/Apestein/nextflix/assets/107362680/00f9690a-8698-498a-b639-5e45b5e5518c) #### 7. To prevent the search function from firing with every keystroke. Use the [use-debounce package](https://www.npmjs.com/package/use-debounce). [See my implementation here](). All data fetching can be done with server component by using router.push()/replace(). Pretty crazy pattern if you ask me🤯. [scrnli_8_22_2023_12-51-37 PM4.webm](https://github.com/Apestein/nextflix/assets/107362680/3dda2e70-97f5-4d88-beca-1cfda53fc344) #### 8. Very frustrating problem I ran into was the scrollbar causing layout shift. When users navigate from a page with scrollbar to a page without scrollbar there would be an annoying layout shift. https://github.com/Apestein/nextflix/assets/107362680/4136a245-e38f-404a-b66e-2a9c4bc1b266 The solution is to use [scrollbar-gutter css property](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter). However, the documentation is terrible and I couldn't get it to work after many hours of debugging. I ended up just googling "scrollbar-gutter not working" and found this [stackoverflow answer](https://stackoverflow.com/questions/75732399/why-doesnt-scrollbar-gutter-stable-work-on-the-body-element) lol. Basically, I just need to place scrollbar-gutter:stable on the [HTML element](https://github.com/Apestein/nextflix/blob/main/src/app/layout.tsx)🤦‍♂️. However this introduced a new bug when using "scrollbar-gutter" with modals, specifically Shadcn/ui & Radix modals. Opening a modal causes layout shift. [scrnli_8_25_2023_5-58-40 PM5.webm](https://github.com/Apestein/nextflix/assets/107362680/9e5e7f93-8d1a-493f-8eba-c6dc2b283805) Moreover, since I must set "scrollbar-gutter" on the HTML element. This meant that my whole application will have a gutter (small padding on the right) on every page, we wouldn't want a gutter on our auth pages for example. After some research, here is the solution that I came up with: ```ts //app/layout.tsx ... ``` ```ts //app/(main)/layout.tsx
{children}
``` Edit: Unfortunately, the solution above introduced some new layout shift. But I think the solution is cool so I will leave it here. This is the new solution, I just force a scrollbar on the (main) route group. Not the perfect solution but it's the best I can up come with. ```ts //app/layout.tsx ... ``` Edit: Now I'm using [OverlayScrollbars](https://kingsora.github.io/OverlayScrollbars/). I think this is the best solution. See my implementation [here](https://github.com/Apestein/nextflix/blob/main/src/components/overlay-scrollbar.tsx). #### 9. For modal using [intercepting route](https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes#modals). Follow next.js [official example(https://github.com/vercel-labs/nextgram). You can only use router.back() to close the modal as far as I know. By default when opening the intercepting modal, it will cause page to scroll either all the way up or down. To prevent this, set scroll={false} on Link. ```ts ``` If you have a loading.tsx file for the modal it should go in the @modal folder [like this](). You can see just how much of different intercepting modal makes vs normal modal by looking at my previous solution. [Previous](https://github.com/Apestein/nextflix/blob/normal-modal/src/components/show-card.tsx) vs [New]() #### 10. To deploy to the edge, you only need 2 lines of code. Since my database is located in US East, edge can be [slower than normal serverless lambda](https://vercel.com/docs/functions/edge-functions#using-a-database-with-edge-functions) if I don't set a preferredRegion close to my database location. Currently, there is bug with Clerk and Next.js in local development if you're on Windows. Just comment out the edge runtime export when in development, when you deploy to vercel it should be fine. ```ts export const runtime = "edge" export const preferredRegion = "iad1" ``` #### 11. Next.js image component is more complicated than you think, check out this [video](https://www.youtube.com/watch?v=gpJKj45AikY). To optimize, first I request the smallest resolution necessary. TMDB api won't let us request anything smaller than 300w, but ideally it should be 240w since I know that's the maximum size it can be. You should use Next.js Image component here, I can't with my app because hobby plan has limits on image optimization. ```ts show-backdrop ``` #### 12. You should move your linting and typechecking to Github workflows instead of Vercel, this will [greatly reduce build times](https://youtu.be/YkOSUVzOAA4?t=10047). It's very easy, just include this [file](https://github.com/Apestein/nextflix/blob/7170c65c9928bbaf296196bdc54fd4e43e64a1bb/.github/workflows/ci.yml) in .github/workflows folder. "DATABASE_URL" can be any valid URL. If using T3 Env or bootrapping with CreateT3App set "SKIP_ENV_VALIDATION: true" to skip env check on Github. #### 13. Carousel is created using [react-snap-carousel](https://github.com/richardscarrott/react-snap-carousel). Carousel drag scroll is implement using [react-use-draggable-scroll](https://github.com/rfmiotto/react-use-draggable-scroll). See my implementation [here](https://github.com/Apestein/nextflix/blob/main/src/components/show-carousel.tsx). ### Follow and ask me questions at [@Apestein_Dev](https://twitter.com/Apestein_Dev). ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true }, "aliases": { "components": "~/components", "utils": "~/lib/utils" } } ================================================ FILE: drizzle.config.ts ================================================ import type { Config } from "drizzle-kit" import "dotenv/config" export default { schema: "./src/db/schema.ts", out: "./drizzle", driver: "pg", dbCredentials: { connectionString: process.env.DATABASE_URL!, }, schemaFilter: ["public"], verbose: true, strict: true, } satisfies Config ================================================ FILE: next.config.mjs ================================================ /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * for Docker builds. */ await import("./src/env.mjs") /** @type {import("next").NextConfig} */ const config = { images: { domains: ["image.tmdb.org", "img.clerk.com", "api.dicebear.com"], }, experimental: { serverActions: true, }, typescript: { ignoreBuildErrors: true, }, eslint: { ignoreDuringBuilds: true, }, } export default config ================================================ FILE: package.json ================================================ { "name": "nextflix", "version": "0.1.0", "private": true, "scripts": { "build": "next build", "dev": "next dev", "lint": "next lint", "typecheck": "tsc", "start": "next start", "introspect": "drizzle-kit introspect:pg", "generate": "drizzle-kit generate:pg", "migrate": "tsx ./src/db/migrate.ts", "push": "drizzle-kit push:pg", "studio": "drizzle-kit studio", "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe" }, "dependencies": { "@clerk/nextjs": "^4.23.3", "@neondatabase/serverless": "^0.6.0", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-scroll-area": "^1.0.4", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.4", "@t3-oss/env-nextjs": "^0.6.1", "@vercel/analytics": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "dotenv": "^16.3.1", "drizzle-orm": "^0.28.5", "lucide-react": "^0.274.0", "next": "^13.4.19", "next-safe-action": "^3.0.1", "next-themes": "^0.2.1", "overlayscrollbars-react": "^0.5.2", "pg": "^8.11.3", "postgres": "^3.3.5", "react": "18.2.0", "react-dom": "18.2.0", "react-snap-carousel": "^0.3.2", "react-use-draggable-scroll": "^0.4.7", "stripe": "^13.4.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "use-debounce": "^9.0.4", "zod": "^3.22.2", "zod-validation-error": "^1.5.0" }, "devDependencies": { "@types/eslint": "^8.44.2", "@types/node": "^20.5.9", "@types/prettier": "^2.7.3", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.5.0", "@typescript-eslint/parser": "^6.5.0", "autoprefixer": "^10.4.15", "drizzle-kit": "^0.19.13", "eslint": "^8.48.0", "eslint-config-next": "^13.4.19", "postcss": "^8.4.29", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.4", "tailwind-scrollbar": "^3.0.5", "tailwindcss": "^3.3.3", "tsx": "^3.12.8", "typescript": "^5.2.2" } } ================================================ FILE: postcss.config.cjs ================================================ const config = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; module.exports = config; ================================================ FILE: prettier.config.cjs ================================================ /** @type {import("prettier").Config} */ const config = { plugins: [require.resolve("prettier-plugin-tailwindcss")], semi: false, }; module.exports = config; ================================================ FILE: src/actions/index.ts ================================================ "use server" import { z } from "zod" import { authAction } from "./safe-action-client" import { db } from "~/db/client" import { eq } from "drizzle-orm" import { accounts, profiles, myShows } from "~/db/schema" import { ERR } from "~/lib/utils" import { revalidatePath } from "next/cache" import { getAccount, getAccountWithProfiles, getProfile, getAccountWithActiveProfile, getMyShowsFromTmdb, } from "~/lib/server-fetchers" import { stripe } from "~/lib/stripe" import { headers } from "next/headers" import { redirect } from "next/navigation" import type { Stripe } from "stripe" import { planTuple } from "~/lib/configs" import { MediaTuple } from "~/lib/types" export const createProfile = authAction( z.object({ name: z.string().min(2).max(20), }), async (input, { userId }) => { const account = await getAccountWithProfiles() if (account.profiles.length === 4) throw new Error(ERR.not_allowed) const takenProfileSlots = account.profiles.map((profile) => Number(profile.id.at(-1)), ) const openProfileSlot = [1, 2, 3, 4].find( (el) => !takenProfileSlots.includes(el), ) if (!openProfileSlot) throw new Error(ERR.undefined) await db.insert(profiles).values({ id: `${userId}-${openProfileSlot}`, accountId: userId, name: input.name, profileImgPath: `https://api.dicebear.com/6.x/bottts-neutral/svg?seed=${input.name}`, }) revalidatePath("/manage-profile") return { message: "Profile Created" } }, ) export const deleteProfile = authAction( z.object({ profileId: z.string(), }), async (input) => { const account = await getAccountWithProfiles() if (account.activeProfileId === input.profileId) return { message: "Cannot delete active profile" } if (!account.profiles.find((profile) => profile.id === input.profileId)) throw new Error(ERR.unauthorized) await db.delete(profiles).where(eq(profiles.id, input.profileId)) revalidatePath("/manage-profile") return { message: "Profile Deleted" } }, ) export const updateProfile = authAction( z.object({ profileId: z.string(), name: z.string().min(2).max(20), }), async (input, { userId }) => { const profile = await getProfile(input.profileId) if (userId !== profile.accountId) throw new Error(ERR.unauthorized) await db .update(profiles) .set({ name: input.name, profileImgPath: `https://api.dicebear.com/6.x/bottts-neutral/svg?seed=${input.name}`, }) .where(eq(profiles.id, input.profileId)) revalidatePath("/manage-profile") return { message: "Profile Updated" } }, ) export const switchProfile = authAction( z.object({ profileId: z.string(), }), async (input, { userId }) => { const profile = await getProfile(input.profileId) if (profile.accountId !== userId) throw new Error(ERR.unauthorized) await db .update(accounts) .set({ activeProfileId: input.profileId, }) .where(eq(accounts.id, userId)) revalidatePath("/") return { message: "You have switched active profile" } }, ) export const toggleMyShow = authAction( z.object({ id: z.number(), isSaved: z.boolean(), movieOrTv: z.enum(MediaTuple), }), async (input) => { const account = await getAccount() if (!input.isSaved) { await db.insert(myShows).values({ id: input.id, mediaType: input.movieOrTv, profileId: account.activeProfileId, }) return { isSaved: true } } else { await db.delete(myShows).where(eq(myShows.id, input.id)) return { isSaved: false } } }, ) export const createCheckoutSession = authAction( z.object({ stripeProductId: z.string(), planName: z.enum(planTuple), }), async (input, { userId }) => { const account = await getAccount() const siteUrl = headers().get("origin")! let checkoutSession: Stripe.Checkout.Session | Stripe.BillingPortal.Session if (input.planName !== "free" && account.membership === "free") checkoutSession = await stripe.checkout.sessions.create({ mode: "subscription", billing_address_collection: "auto", customer_email: account.email, line_items: [ { price: input.stripeProductId, quantity: 1, }, ], success_url: `${siteUrl}/subscription/result?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${siteUrl}/subscription`, metadata: { userId, planName: input.planName, }, }) else checkoutSession = await stripe.billingPortal.sessions.create({ customer: account.stripeCustomerId!, return_url: `${siteUrl}/subscription`, }) redirect(checkoutSession.url!) }, ) export const getMyShowsInfinite = authAction( z.object({ index: z.number().min(0), limit: z.number().min(2).max(50), }), async (input) => { const account = await getAccountWithActiveProfile() const shows = await db.query.myShows.findMany({ where: eq(myShows.profileId, account.activeProfileId), limit: input.limit + 1, offset: input.index * input.limit, }) const hasNextPage = shows.length > input.limit ? true : false if (hasNextPage) shows.pop() const filteredShows = await getMyShowsFromTmdb(shows) return { shows: filteredShows, hasNextPage } }, ) ================================================ FILE: src/actions/safe-action-client.ts ================================================ /* eslint-disable @typescript-eslint/require-await */ import { createSafeActionClient } from "next-safe-action" import { auth } from "@clerk/nextjs" import { ERR } from "~/lib/utils" export const action = createSafeActionClient() export const authAction = createSafeActionClient({ buildContext: async () => { const userId = auth().userId if (!userId) throw new Error(ERR.unauthenticated) return { userId, } }, }) ================================================ FILE: src/app/(auth)/layout.tsx ================================================ export default function AuthLayout({ children, }: { children: React.ReactNode }) { return (
{children}
) } ================================================ FILE: src/app/(auth)/sign-in/[[...sign-in]]/page.tsx ================================================ import { SignIn } from "@clerk/nextjs" export default function Page() { return } ================================================ FILE: src/app/(auth)/sign-up/[[...sign-up]]/page.tsx ================================================ import { SignUp } from "@clerk/nextjs" export default function Page() { return } ================================================ FILE: src/app/(main)/@modal/(.)show/[id]/modal.tsx ================================================ "use client" import type { ShowWithVideoAndGenre } from "~/lib/types" import { useRef, useEffect } from "react" import { useRouter } from "next/navigation" import { ModalCard } from "~/components/modal-card" export function Modal({ show, isSaved, }: { show: ShowWithVideoAndGenre isSaved?: boolean }) { const overlay = useRef(null) const router = useRouter() useEffect(() => { const back = (e: KeyboardEvent) => e.key === "Escape" && router.back() document.addEventListener("keydown", back) return () => document.removeEventListener("keydown", back) }, []) return (
e.target === overlay.current && router.back()} className="fixed inset-0 bg-black/60" id="show-modal" >
) } ================================================ FILE: src/app/(main)/@modal/(.)show/[id]/page.tsx ================================================ import { Modal } from "./modal" import type { MediaType } from "~/lib/types" import { getShowVideoAndGenreWithStatus } from "~/lib/server-fetchers" export default async function ShowModal(props: { params: { id: number } searchParams: { mediaType: MediaType } }) { const { show, isSaved } = await getShowVideoAndGenreWithStatus( props.params.id, props.searchParams.mediaType, ) return } ================================================ FILE: src/app/(main)/@modal/default.tsx ================================================ export default function Default() { return null } ================================================ FILE: src/app/(main)/@modal/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/account/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/account/page.tsx ================================================ import { CreditCard, ChevronRight } from "lucide-react" import { Button } from "~/components/ui/button" import Link from "next/link" import { getAccountWithProfiles } from "~/lib/server-fetchers" export default async function AccountPage() { const account = await getAccountWithProfiles() return (

Account

Member Since: {account.createdAt.toDateString()}

MEMBERSHIP & BILLING

{account.email}

Update Account

Plan Details

{`${account.membership .charAt(0) .toUpperCase()}${account.membership.substring(1)}`} 4K+HDR

Change plan

Profiles

{account.profiles.map((profile) => (
{/* eslint-disable-next-line @next/next/no-img-element */} profile-img

{profile.name}

))}
) } ================================================ FILE: src/app/(main)/default.tsx ================================================ export default function Default() { return null } ================================================ FILE: src/app/(main)/layout.tsx ================================================ import Image from "next/image" import Link from "next/link" import { currentUser, SignedOut, auth, SignOutButton } from "@clerk/nextjs" import { Suspense } from "react" import { Button } from "~/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu" import { Skeleton } from "~/components/ui/skeleton" import { db } from "~/db/client" import { accounts, profiles } from "~/db/schema" import { eq } from "drizzle-orm" import { ERR } from "~/lib/utils" import { Search, Bell, Facebook, Instagram, Twitter, Youtube, Home, Clapperboard, Film, TrendingUp, List, Pencil, ArrowLeftRight, User, BadgeCheck, } from "lucide-react" import { LinkButton } from "~/components/link-button" import { getAccountWithActiveProfile } from "~/lib/server-fetchers" import { OverlayScrollbar } from "~/components/overlay-scrollbar" export default function ShowsLayout({ children, modal, }: { children: React.ReactNode modal: React.ReactNode }) { return (
{children} {modal}
) } const NAVINFO = [ { name: "Home", href: "/", icon: }, { name: "TV Shows", href: "/tv-shows", icon: , }, { name: "Movies", href: "/movies", icon: }, { name: "New & Popular", href: "/new-and-popular", icon: , }, { name: "My List", href: "/my-list", icon: }, ] function Header() { return (
netflix-logo
}>
) } async function CustomeUserButton() { const { userId } = auth() if (!userId) return const existingAccount = await db.query.accounts.findFirst({ where: eq(accounts.id, userId), with: { activeProfile: true }, }) const account = existingAccount ?? (await createAccountAndProfile()) return ( {/* eslint-disable-next-line @next/next/no-img-element */} user-image {account.activeProfile.name} Manage Profile Switch Profile Account Subscription ) } function MainMenu() { return (

Menu

Netflix {NAVINFO.map((el) => ( {el.icon} {el.name} ))}
) } function Footer() { return (
Audio Description Investor Relations Legal Notices
Help Center Jobs Cookie Preferences
Gift Cards Terms of Use Corporate Information
Media Center Privacy Contact Us
Built by Apestein. The source code is available on  Github
) } async function createAccountAndProfile() { const user = await currentUser() if (!user) throw new Error(ERR.unauthenticated) await db .insert(accounts) .values({ id: user.id, email: user.emailAddresses[0]!.emailAddress, activeProfileId: user.id + "-1", }) .onConflictDoNothing() await db .insert(profiles) .values({ id: user.id + "-1", accountId: user.id, profileImgPath: `https://api.dicebear.com/6.x/bottts-neutral/svg?seed=${ user.username ?? user.firstName ?? user.emailAddresses[0]!.emailAddress }`, name: user.username ?? user.firstName ?? user.emailAddresses[0]!.emailAddress, }) .onConflictDoNothing() return getAccountWithActiveProfile() } ================================================ FILE: src/app/(main)/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/movies/page.tsx ================================================ import { ShowsCarousel } from "~/components/show-carousel" import { getShows } from "~/lib/client-fetchers" import { ShowBg } from "../../../components/show-bg" import { ShowHero } from "../../../components/show-hero" import { pickRandomShow } from "~/lib/utils" export default async function Movies() { const allShows = await getShows("movie") const randomShow = pickRandomShow(allShows.trending) return ( <>
) } ================================================ FILE: src/app/(main)/my-list/infinite-scroller.tsx ================================================ "use client" import { useEffect, useRef, useState } from "react" import type { Show } from "~/lib/types" import { getMyShowsInfinite } from "~/actions" import { getShows } from "~/lib/client-fetchers" import { ERR } from "~/lib/utils" import { Button } from "~/components/ui/button" import Link from "next/link" export function ShowScroller({ initialShows, initialHasNextPage, limit, }: { initialShows: Show[] initialHasNextPage: boolean limit: number }) { const [myShows, setMyShows] = useState(initialShows) const [simulatedShows, setSimulatedShows] = useState() const getShowsReturnRef = useRef>>() const hasNextPageRef = useRef(initialHasNextPage) const indexRef = useRef(0) const observerTarget = useRef(null) const shows = simulatedShows ?? myShows async function fetchNextPage() { indexRef.current += 1 const { data } = await getMyShowsInfinite({ index: indexRef.current, limit, }) if (!data) throw new Error(ERR.db) setMyShows((prev) => [...prev, ...data.shows]) hasNextPageRef.current = data.hasNextPage } async function getSimulatedShows() { if (getShowsReturnRef.current) { window.scrollTo(0, 0) getShowsReturnRef.current = undefined hasNextPageRef.current = initialHasNextPage setSimulatedShows(undefined) return } window.scrollTo(0, 0) const data = await getShows("movie") getShowsReturnRef.current = data hasNextPageRef.current = true setSimulatedShows([ ...new Map( [...data.trending, ...data.topRated].map((item) => [item.id, item]), ).values(), ]) } useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (!hasNextPageRef.current) return if (entries[0]?.isIntersecting) { if (getShowsReturnRef.current) { setTimeout( () => setSimulatedShows((prev) => [ ...new Map( [ ...prev!, ...getShowsReturnRef.current!.actionThriller, ...getShowsReturnRef.current!.comedy, ].map((item) => [item.id, item]), ).values(), ]), 1000, ) hasNextPageRef.current = false } else void fetchNextPage() } }, { threshold: 1 }, ) if (observerTarget.current) { observer.observe(observerTarget.current) } return () => observer.disconnect() }, []) return (
    {shows.map((show) => ( {/* eslint-disable-next-line @next/next/no-img-element */} show-backdrop ))}
{hasNextPageRef.current ? ( ) : ( )} {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
) } ================================================ FILE: src/app/(main)/my-list/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/my-list/page.tsx ================================================ import { ShowScroller } from "./infinite-scroller" import { getMyShows } from "~/lib/server-fetchers" export default async function MyShowPage() { const LIMIT = 30 const data = await getMyShows(LIMIT) return (
{!data.shows.length && (

Your list is empty

Add shows and movies to your list to watch them later

)}
) } ================================================ FILE: src/app/(main)/new-and-popular/page.tsx ================================================ import type { Show } from "~/lib/types" import { ShowsCarousel } from "~/components/show-carousel" import { ERR } from "~/lib/utils" import { env } from "~/env.mjs" import { ShowBg } from "../../../components/show-bg" import { ShowHero } from "../../../components/show-hero" import { pickRandomShow } from "~/lib/utils" export default async function NewAndPopular() { const newAndPopularShows = await getNewAndPopularShows() const randomShow = pickRandomShow(newAndPopularShows.trendingMovies) return ( <>
) } async function getNewAndPopularShows() { const [popularTvRes, popularMovieRes, trendingTvRes, trendingMovieRes] = await Promise.all([ fetch( `https://api.themoviedb.org/3/tv/popular?api_key=${env.NEXT_PUBLIC_TMDB_API}`, ), fetch( `https://api.themoviedb.org/3/movie/popular?api_key=${env.NEXT_PUBLIC_TMDB_API}`, ), fetch( `https://api.themoviedb.org/3/trending/tv/day?api_key=${env.NEXT_PUBLIC_TMDB_API}`, ), fetch( `https://api.themoviedb.org/3/trending/movie/day?api_key=${env.NEXT_PUBLIC_TMDB_API}`, ), ]) if ( !popularTvRes.ok || !popularMovieRes.ok || !trendingTvRes.ok || !trendingMovieRes.ok ) { throw new Error(ERR.fetch) } const [popularTvs, popularMovies, trendingTvs, trendingMovies] = await Promise.all<{ results: Show[] }>([ popularTvRes.json(), popularMovieRes.json(), trendingTvRes.json(), trendingMovieRes.json(), ]) if (!popularTvs || !popularMovies || !trendingTvs || !trendingMovies) throw new Error(ERR.fetch) return { popularTvs: popularTvs.results, popularMovies: popularMovies.results, trendingTvs: trendingTvs.results, trendingMovies: trendingMovies.results, } } ================================================ FILE: src/app/(main)/page.tsx ================================================ import { getShows } from "~/lib/client-fetchers" import { ShowHero } from "~/components/show-hero" import { ShowBg } from "~/components/show-bg" import { pickRandomShow } from "~/lib/utils" import { ShowsCarousel } from "~/components/show-carousel" export default async function Home() { const allShows = await getShows("movie") const randomShow = pickRandomShow(allShows.trending) return ( <>
) } ================================================ FILE: src/app/(main)/search/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/search/page.tsx ================================================ import { ERR } from "~/lib/utils" import { env } from "~/env.mjs" import type { Show } from "~/lib/types" import { SearchInput } from "./search-input" import Link from "next/link" export default async function SearchPage({ searchParams, }: { searchParams: { keyword: string } }) { if (!searchParams.keyword) return (
) const shows = await searchShows(searchParams.keyword) return (
{shows.map((show) => show.backdrop_path || show.poster_path ? ( {/* eslint-disable-next-line @next/next/no-img-element */} show-backdrop ) : null, )}
) } async function searchShows(query: string) { const res = await fetch( `https://api.themoviedb.org/3/search/multi?api_key=${env.NEXT_PUBLIC_TMDB_API}&query=${query}`, ) if (!res.ok) throw new Error(ERR.fetch) const shows = (await res.json()) as { results: Show[] } const popularShows = shows.results.sort((a, b) => b.popularity - a.popularity) return popularShows } ================================================ FILE: src/app/(main)/search/search-input.tsx ================================================ "use client" import { Input } from "~/components/ui/input" import { useEffect, useState } from "react" import { useDebouncedCallback } from "use-debounce" import { useRouter } from "next/navigation" interface PageProps extends React.HTMLAttributes { initialQuery: string } export function SearchInput({ initialQuery, ...props }: PageProps) { const [query, setQuery] = useState("") const debounced = useDebouncedCallback((value: string) => { setQuery(value) }, 500) const router = useRouter() useEffect(() => { if (query) router.replace(`/search?keyword=${query}`) }, [query]) return ( debounced(e.target.value)} autoFocus {...props} /> ) } ================================================ FILE: src/app/(main)/show/[id]/page.tsx ================================================ import { ModalCard } from "~/components/modal-card" import type { MediaType } from "~/lib/types" import { getShowVideoAndGenreWithStatus } from "~/lib/server-fetchers" export default async function ShowPage(props: { params: { id: number } searchParams: { mediaType: MediaType } }) { const { show, isSaved } = await getShowVideoAndGenreWithStatus( props.params.id, props.searchParams.mediaType, ) return (
) } ================================================ FILE: src/app/(main)/subscription/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(main)/subscription/page.tsx ================================================ import { Check } from "lucide-react" import { PlanSelector } from "./plan-selector" import { getAccount } from "~/lib/server-fetchers" export default async function SubscriptionPage() { const account = await getAccount() return (

Choose the plan that's right for you

Watch on your phone, tablet, laptop, and TV

Unlimited movies and TV shows

Change or cancel your plan anytime

HD (720p), Full HD (1080p), Ultra HD (4K) and HDR availability subject to your internet service and device capabilities. Not all content is available in all resolutions. See our{" "} Terms of Use for more details.

Only people who live with you may use your account. Watch on 4 different devices at the same time with Premium, 2 with Standard, and 1 with Basic and Mobile.

) } ================================================ FILE: src/app/(main)/subscription/plan-selector.tsx ================================================ "use client" import { cn } from "~/lib/utils" import { useState } from "react" import { Button } from "~/components/ui/button" import type { SubscriptionPlan, PlanName } from "~/lib/types" import { PLANS } from "~/lib/configs" import { createCheckoutSession } from "~/actions" export function PlanSelector({ activeSubscription, }: { activeSubscription: PlanName }) { const [selectedPlan, setSelectedPlan] = useState( Plans[activeSubscription], ) function submit() { void createCheckoutSession({ stripeProductId: selectedPlan.id, planName: selectedPlan.name, }) } return ( <>
{PLANS.map((plan) => (
setSelectedPlan(plan)} > {`${plan.name.charAt(0).toUpperCase()}${plan.name.substring(1)}`}
))}
) } const Plans = { free: PLANS[0], basic: PLANS[1], standard: PLANS[2], premium: PLANS[3], } ================================================ FILE: src/app/(main)/subscription/result/page.tsx ================================================ // import type { Stripe } from "stripe" import { stripe } from "~/lib/stripe" import { ScrollArea } from "~/components/ui/scroll-area" export default async function ResultPage({ searchParams, }: { searchParams: { session_id: string } }): Promise { if (!searchParams.session_id) throw new Error("Please provide a valid session_id (`cs_test_...`)") const checkoutSession = await stripe.checkout.sessions.retrieve( searchParams.session_id, { expand: ["line_items", "payment_intent"], }, ) const checkoutStatus = checkoutSession.payment_status const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }) const amountPaid = currencyFormatter.format( checkoutSession.amount_subtotal! / 100, ) const createdAt = new Date(checkoutSession.created) return (

Checkout Status: {checkoutStatus}

Checkout Session Info:

{/* */}

{`Email: ${checkoutSession.customer_email}`}

{`Amount Total: ${amountPaid}`}

{`Time: ${createdAt.toUTCString()}`}

) } // function PrintObject({ // content, // }: { // content: Stripe.Response // }): JSX.Element { // const formattedContent: string = JSON.stringify(content, null, 2) // return
{formattedContent}
// } ================================================ FILE: src/app/(main)/tv-shows/page.tsx ================================================ import { ShowsCarousel } from "~/components/show-carousel" import { getShows } from "~/lib/client-fetchers" import { ShowBg } from "../../../components/show-bg" import { ShowHero } from "../../../components/show-hero" import { pickRandomShow } from "~/lib/utils" export default async function TvShows() { const allShows = await getShows("tv") const randomShow = pickRandomShow(allShows.topRated) return ( <>
) } ================================================ FILE: src/app/(profile)/loading.tsx ================================================ import { Skeleton } from "~/components/ui/skeleton" export default function Loading() { return (
) } ================================================ FILE: src/app/(profile)/manage-profile/[...slug]/page.tsx ================================================ "use client" import { Button } from "~/components/ui/button" import { useState } from "react" import { useDebouncedCallback } from "use-debounce" import { useRouter } from "next/navigation" import { useToast } from "~/components/ui/use-toast" import { Input } from "~/components/ui/input" import { ArrowLeft } from "lucide-react" import Link from "next/link" import { deleteProfile, updateProfile } from "~/actions" export default function ProfilePage({ params, searchParams, }: { params: { slug: string[] } searchParams: { profileId: string } }) { const [name, setName] = useState(params.slug[0]!) const debounced = useDebouncedCallback((value: string) => { setName(value) }, 500) const router = useRouter() const { toast } = useToast() async function doDelete() { const { data, validationError } = await deleteProfile({ profileId: searchParams.profileId, }) toast({ description: data?.message ?? validationError?.profileId, }) if (data) router.replace("/manage-profile") } async function doUpdate() { const { data, validationError } = await updateProfile({ profileId: searchParams.profileId, name, }) toast({ description: data?.message ?? JSON.stringify(validationError, null, 4), }) if (data) router.replace("/manage-profile") } return ( <>

Update Profile

Update a profile with a new name and avatar.

{/* eslint-disable-next-line @next/next/no-img-element */} profile-image debounced(e.target.value)} />
) } ================================================ FILE: src/app/(profile)/manage-profile/add/page.tsx ================================================ "use client" import { Button } from "~/components/ui/button" import { Input } from "~/components/ui/input" import { useState } from "react" import { useDebouncedCallback } from "use-debounce" import { useRouter } from "next/navigation" import Link from "next/link" import { ArrowLeft } from "lucide-react" import { useToast } from "~/components/ui/use-toast" import { createProfile } from "~/actions" export default function AddProfilePage() { const [name, setName] = useState("") const debounced = useDebouncedCallback((value: string) => { setName(value) }, 500) const router = useRouter() const { toast } = useToast() async function doAdd() { const { data, validationError } = await createProfile({ name }) toast({ description: data?.message ?? validationError?.name ?? "Name must be unique", }) if (data) router.replace("/manage-profile") } return ( <>

Add Profile

Add a profile for another person watching Netflix.

{/* eslint-disable-next-line @next/next/no-img-element */} profile-image debounced(e.target.value)} /> {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
) } ================================================ FILE: src/app/(profile)/manage-profile/page.tsx ================================================ import { Button } from "~/components/ui/button" import { PlusCircle, ArrowLeft, Pencil } from "lucide-react" import Link from "next/link" import { getAccountWithProfiles } from "~/lib/server-fetchers" export default async function ManageProfilePage() { const account = await getAccountWithProfiles() return ( <>

Manage Profiles

    {account.profiles.map((profile) => (
    {/* eslint-disable-next-line @next/next/no-img-element */} profile-image

    {profile.name}

    ))} {account.profiles.length !== 4 && ( )}
) } ================================================ FILE: src/app/(profile)/switch-profile/page.tsx ================================================ import { Button } from "~/components/ui/button" import { ArrowLeft } from "lucide-react" import Link from "next/link" import { ProfileSwitcher } from "./profile-switcher" import { getAccountWithProfiles } from "~/lib/server-fetchers" export default async function SwitchProfilePage() { const account = await getAccountWithProfiles() return ( <>

Who's Watching

    {account.profiles.map((profile) => ( ))}
) } ================================================ FILE: src/app/(profile)/switch-profile/profile-switcher.tsx ================================================ "use client" import type { Profile } from "~/lib/types" import { useRouter } from "next/navigation" import { useToast } from "~/components/ui/use-toast" import { switchProfile } from "~/actions" export function ProfileSwitcher({ profile }: { profile: Profile }) { const router = useRouter() const { toast } = useToast() async function doSwitch() { const { data, validationError } = await switchProfile({ profileId: profile.id, }) toast({ description: data?.message ?? validationError?.profileId }) if (data) router.replace("/") } return (
{/* eslint-disable-next-line @next/next/no-img-element */} profile-image

{profile.name}

) } ================================================ FILE: src/app/api/(webhook)/stripe/route.ts ================================================ import type { Stripe } from "stripe" import { stripe } from "~/lib/stripe" import { NextResponse } from "next/server" import { db } from "~/db/client" import { accounts } from "~/db/schema" import { eq } from "drizzle-orm" import { headers } from "next/headers" import { env } from "~/env.mjs" import { planTuple } from "~/lib/configs" import { z } from "zod" export const runtime = "nodejs" export async function POST(req: Request) { const body = await req.text() const signature = headers().get("Stripe-Signature")! let event: Stripe.Event try { event = stripe.webhooks.constructEvent( body, signature, env.NODE_ENV === "production" ? env.STRIPE_WEBHOOK_SECRET : env.STRIPE_DEV_WEBHOOK_SECRET, ) } catch (error) { return new Response( `Webhook Error: ${ error instanceof Error ? error.message : "Unknown error" }`, { status: 400 }, ) } if (event.type === "checkout.session.completed") { const session = event.data.object as Stripe.Checkout.Session // Retrieve the subscription details from Stripe. const subscription = await stripe.subscriptions.retrieve( session.subscription as string, ) // Update the user stripe into in our database. const userId = session.metadata!.userId! const plan = session.metadata!.planName! const planSchema = z.enum(planTuple) const validatedPlan = planSchema.parse(plan) await db .update(accounts) .set({ stripeCustomerId: subscription.customer as string, membership: validatedPlan, }) .where(eq(accounts.id, userId)) } if (event.type === "customer.subscription.updated") { const data = event.data.object as Stripe.Subscription if (data.canceled_at) await db .update(accounts) .set({ membership: "free" }) .where(eq(accounts.stripeCustomerId, data.customer as string)) } return NextResponse.json({ message: "Received" }, { status: 200 }) } ================================================ FILE: src/app/error.tsx ================================================ "use client" import { useEffect } from "react" import { Button } from "~/components/ui/button" export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { useEffect(() => { console.error(error) }, [error]) return (

There was a problem

{error.message}

) } ================================================ FILE: src/app/layout.tsx ================================================ import "~/lib/globals.css" import { Inter } from "next/font/google" import { cn } from "~/lib/utils" import { ThemeProvider } from "~/components/theme-provider" import { ClerkProvider } from "@clerk/nextjs" import { Toaster } from "~/components/ui/toaster" import { Analytics } from "@vercel/analytics/react" export const runtime = "edge" export const preferredRegion = "iad1" const inter = Inter({ subsets: ["latin"] }) const siteConfig = { title: "Netflix Clone", description: "Open source project using bleeding-edge stack. Drizzle ORM + Neon postgres + Clerk auth + Shadcn/ui + everything new in Next.js 13 (server components, server actions, streaming ui, parallel routes, intercepting routes).", url: "/", siteName: "Nextflix", } export const metadata = { metadataBase: new URL("https://nextflix-blush.vercel.app"), title: siteConfig.title, description: siteConfig.description, openGraph: { title: siteConfig.title, description: siteConfig.description, url: siteConfig.url, siteName: siteConfig.siteName, locale: "en_US", type: "website", }, twitter: { card: "summary_large_image", title: siteConfig.title, }, } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( {children} ) } ================================================ FILE: src/app/not-found.tsx ================================================ import Link from "next/link" import { Button } from "~/components/ui/button" export default function NotFound() { return (

Not Found

Could not find requested resource

) } ================================================ FILE: src/components/link-button.tsx ================================================ "use client" import { useRouter } from "next/navigation" export function LinkButton({ children, href, }: { children: React.ReactNode href: string }) { const router = useRouter() return ( ) } ================================================ FILE: src/components/modal-card.tsx ================================================ "use client" import type { ShowWithVideoAndGenre } from "~/lib/types" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { PlusCircle, CheckCircle } from "lucide-react" import { toggleMyShow } from "~/actions" import { useOptimisticAction } from "next-safe-action/hook" interface ModalCardProps extends React.ComponentPropsWithoutRef<"div"> { show: ShowWithVideoAndGenre isSaved?: boolean } export function ModalCard({ show, isSaved, ...props }: ModalCardProps) { const trailer = show.videos.results.find((el) => el.type === "Trailer") return ( {show.title ?? show.name} {isSaved !== undefined && ( )}

{Math.round((show.vote_average * 100) / 10)}% Match

{show.release_date?.substring(0, 4) ?? show.first_air_date?.substring(0, 4)}

EN

{show.overview}

{show.genres.map((genre) => genre.name).join(", ")}

{trailer ? (