├── cypress.json ├── app ├── types │ ├── ActorItem.ts │ ├── Director.ts │ ├── Actor.ts │ ├── Video.ts │ ├── PropsWithChildrenFunction.ts │ ├── AffinityMap.ts │ ├── MovieItem.ts │ └── ShowItem.ts ├── images │ └── background.jpg ├── entry.client.tsx ├── components │ ├── virtual-search-box.tsx │ ├── icons │ │ ├── play.tsx │ │ ├── corner-down-left.tsx │ │ └── spinner.tsx │ ├── image-with-loader.tsx │ ├── video-with-preview.tsx │ ├── youtube-video.tsx │ ├── logos │ │ └── webflix.tsx │ └── autocomplete.tsx ├── styles │ └── app.css ├── search-client.ts ├── constants.ts ├── routes │ ├── logout.tsx │ ├── healthcheck.tsx │ ├── watch │ │ └── show │ │ │ └── $itemId.tsx │ ├── join.tsx │ ├── login.tsx │ ├── index.tsx │ └── search.tsx ├── utils.test.ts ├── hooks │ └── useHotkeys.ts ├── entry.server.tsx ├── db.server.ts ├── models │ ├── note.server.ts │ ├── user.server.ts │ └── show.server.ts ├── root.tsx ├── session.server.ts ├── utils.ts └── layout │ └── main.tsx ├── .github ├── banner.jpg └── workflows │ └── deploy.yml ├── public └── favicon.ico ├── cypress ├── support │ ├── index.ts │ ├── delete-user.ts │ ├── create-user.ts │ └── commands.ts ├── .eslintrc.js ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── tsconfig.json └── e2e │ └── smoke.ts ├── .dockerignore ├── .env.example ├── .prettierignore ├── remix.env.d.ts ├── mocks ├── index.js ├── start.ts └── README.md ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20220520220024_remove_notes │ │ └── migration.sql │ ├── 20220515012952_viewed_episodes │ │ └── migration.sql │ ├── 20220307190657_init │ │ └── migration.sql │ └── 20220514183337_models │ │ └── migration.sql ├── schema.prisma └── seed.ts ├── test └── setup-test-env.ts ├── remix.init ├── gitignore ├── package.json └── index.js ├── .gitignore ├── remix.config.js ├── .gitpod.Dockerfile ├── start.sh ├── vitest.config.ts ├── .eslintrc.js ├── README.md ├── tailwind.config.js ├── tsconfig.json ├── fly.toml ├── .gitpod.yml ├── Dockerfile └── package.json /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /app/types/ActorItem.ts: -------------------------------------------------------------------------------- 1 | export type ActorItem = { label: string }; 2 | -------------------------------------------------------------------------------- /.github/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarahdayan/webflix/HEAD/.github/banner.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarahdayan/webflix/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands"; 2 | import "./commands"; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /app/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarahdayan/webflix/HEAD/app/images/background.jpg -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/types/Director.ts: -------------------------------------------------------------------------------- 1 | export type Director = { 2 | name: string; 3 | facet: string; 4 | profile_path: string | null; 5 | }; 6 | -------------------------------------------------------------------------------- /mocks/index.js: -------------------------------------------------------------------------------- 1 | require("tsconfig-paths/register"); 2 | require("ts-node").register({ transpileOnly: true }); 3 | require("./start"); 4 | -------------------------------------------------------------------------------- /app/types/Actor.ts: -------------------------------------------------------------------------------- 1 | export type Actor = { 2 | name: string; 3 | facet: string; 4 | profile_path: string | null; 5 | character: string; 6 | }; 7 | -------------------------------------------------------------------------------- /app/types/Video.ts: -------------------------------------------------------------------------------- 1 | export type Video = { 2 | name: string; 3 | site: "YouTube" | "Vimeo"; 4 | key: string; 5 | published_at: number; 6 | }; 7 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: "./tsconfig.json", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/types/PropsWithChildrenFunction.ts: -------------------------------------------------------------------------------- 1 | export type PropsWithChildrenFunction = TProps & { 2 | children?(params: TParams): React.ReactNode; 3 | }; 4 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /test/setup-test-env.ts: -------------------------------------------------------------------------------- 1 | import { installGlobals } from "@remix-run/node/globals"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | 4 | installGlobals(); 5 | -------------------------------------------------------------------------------- /app/types/AffinityMap.ts: -------------------------------------------------------------------------------- 1 | import type { Affinity } from "@prisma/client"; 2 | 3 | export type AffinityMap = Record< 4 | string, 5 | { affinity: Affinity; data: TData[] } 6 | >; 7 | -------------------------------------------------------------------------------- /app/components/virtual-search-box.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchBox } from "react-instantsearch-hooks-web"; 2 | 3 | export function VirtualSearchBox() { 4 | useSearchBox(); 5 | 6 | return null; 7 | } 8 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /remix.init/gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /prisma/data.db 10 | /prisma/data.db-journal 11 | 12 | /app/styles/tailwind.css 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | .DS_Store 7 | 8 | /cypress/screenshots 9 | /cypress/videos 10 | /prisma/data.db 11 | /prisma/data.db-journal 12 | 13 | /app/styles/tailwind.css 14 | /crawl -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | cacheDirectory: "./node_modules/.cache/remix", 6 | ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], 7 | }; 8 | -------------------------------------------------------------------------------- /app/styles/app.css: -------------------------------------------------------------------------------- 1 | [type="search"]::-webkit-search-decoration, 2 | [type="search"]::-webkit-search-cancel-button, 3 | [type="search"]::-webkit-search-results-button, 4 | [type="search"]::-webkit-search-results-decoration { 5 | appearance: none; 6 | } 7 | -------------------------------------------------------------------------------- /app/search-client.ts: -------------------------------------------------------------------------------- 1 | import algoliasearch from "algoliasearch/lite"; 2 | 3 | import { ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY } from "~/constants"; 4 | 5 | export const searchClient = algoliasearch( 6 | ALGOLIA_APP_ID, 7 | ALGOLIA_SEARCH_API_KEY 8 | ); 9 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | # Install Fly 4 | RUN curl -L https://fly.io/install.sh | sh 5 | ENV FLYCTL_INSTALL="/home/gitpod/.fly" 6 | ENV PATH="$FLYCTL_INSTALL/bin:$PATH" 7 | 8 | # Install GitHub CLI 9 | RUN brew install gh 10 | -------------------------------------------------------------------------------- /remix.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@iarna/toml": "^2.2.5", 8 | "inquirer": "^8.2.2", 9 | "sort-package-json": "^1.55.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/constants.ts: -------------------------------------------------------------------------------- 1 | export const ALGOLIA_APP_ID = "8L3BNIKU8L"; 2 | export const ALGOLIA_SEARCH_API_KEY = "8ae67fc912ae72265d56b1e9246ca67a"; 3 | export const ALGOLIA_INDEX_NAME = "movies"; 4 | export const ALGOLIA_FILTERS = "spoken_languages:English AND NOT genres:Kids"; 5 | 6 | export const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/"; 7 | -------------------------------------------------------------------------------- /mocks/start.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | 3 | import "~/utils"; 4 | 5 | const server = setupServer(); 6 | 7 | server.listen({ onUnhandledRequest: "bypass" }); 8 | console.info("🔶 Mock server running"); 9 | 10 | process.once("SIGINT", () => server.close()); 11 | process.once("SIGTERM", () => server.close()); 12 | -------------------------------------------------------------------------------- /mocks/README.md: -------------------------------------------------------------------------------- 1 | # Mocks 2 | 3 | Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests. 4 | 5 | Learn more about how to use this at [mswjs.io](https://mswjs.io/) 6 | 7 | For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts) 8 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | return logout(request); 8 | }; 9 | 10 | export const loader: LoaderFunction = async () => { 11 | return redirect("/"); 12 | }; 13 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | # This file is how Fly starts the server (configured in fly.toml). Before starting 2 | # the server though, we need to run any prisma migrations that haven't yet been 3 | # run, which is why this file exists in the first place. 4 | # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386 5 | 6 | #!/bin/sh 7 | 8 | set -ex 9 | npx prisma migrate deploy 10 | npm run start 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import react from "@vitejs/plugin-react"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | test: { 11 | globals: true, 12 | environment: "happy-dom", 13 | setupFiles: ["./test/setup-test-env.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /app/components/icons/play.tsx: -------------------------------------------------------------------------------- 1 | type PlayProps = React.ComponentProps<"svg">; 2 | 3 | export function Play(props: PlayProps) { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components/icons/corner-down-left.tsx: -------------------------------------------------------------------------------- 1 | type PlayProps = React.ComponentProps<"svg">; 2 | 3 | export function CornerDownLeft(props: PlayProps) { 4 | return ( 5 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { validateEmail } from "./utils"; 2 | 3 | test("validateEmail returns false for non-emails", () => { 4 | expect(validateEmail(undefined)).toBe(false); 5 | expect(validateEmail(null)).toBe(false); 6 | expect(validateEmail("")).toBe(false); 7 | expect(validateEmail("not-an-email")).toBe(false); 8 | expect(validateEmail("n@")).toBe(false); 9 | }); 10 | 11 | test("validateEmail returns true for emails", () => { 12 | expect(validateEmail("kody@example.com")).toBe(true); 13 | }); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20220520220024_remove_notes/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Note` table. If the table is not empty, all the data it contains will be lost. 5 | - A unique constraint covering the columns `[tmdbId]` on the table `Show` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- DropTable 9 | PRAGMA foreign_keys=off; 10 | DROP TABLE "Note"; 11 | PRAGMA foreign_keys=on; 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "Show_tmdbId_key" ON "Show"("tmdbId"); 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | "@remix-run/eslint-config", 7 | "@remix-run/eslint-config/node", 8 | "@remix-run/eslint-config/jest-testing-library", 9 | "prettier", 10 | ], 11 | // we're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but it means we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | React InstantSearch 4 | 5 | 6 |

7 | Webflix is a Netflix clone built with Remix and Algolia. 8 |

9 |

10 | 11 | --- 12 | 13 | ## Development 14 | 15 | ### Initial setup 16 | 17 | ```sh 18 | yarn setup 19 | ``` 20 | 21 | ### Start dev server 22 | 23 | ```sh 24 | yarn dev 25 | ``` 26 | -------------------------------------------------------------------------------- /prisma/migrations/20220515012952_viewed_episodes/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "_EpisodeToUser" ( 3 | "A" TEXT NOT NULL, 4 | "B" TEXT NOT NULL, 5 | CONSTRAINT "_EpisodeToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Episode" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 6 | CONSTRAINT "_EpisodeToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 7 | ); 8 | 9 | -- CreateIndex 10 | CREATE UNIQUE INDEX "_EpisodeToUser_AB_unique" ON "_EpisodeToUser"("A", "B"); 11 | 12 | -- CreateIndex 13 | CREATE INDEX "_EpisodeToUser_B_index" ON "_EpisodeToUser"("B"); 14 | -------------------------------------------------------------------------------- /app/components/icons/spinner.tsx: -------------------------------------------------------------------------------- 1 | type SpinnerProps = React.ComponentProps<"svg">; 2 | 3 | export function Spinner(props: SpinnerProps) { 4 | return ( 5 | 6 | 14 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/hooks/useHotkeys.ts: -------------------------------------------------------------------------------- 1 | import type { HotkeysEvent } from "hotkeys-js"; 2 | import hotkeys from "hotkeys-js"; 3 | import { useCallback, useEffect } from "react"; 4 | 5 | type CallbackFn = (event: KeyboardEvent, handler: HotkeysEvent) => void; 6 | 7 | export function useHotkeys( 8 | keys: string, 9 | callback: CallbackFn, 10 | deps: TDependencies[] = [] 11 | ) { 12 | const memoizedCallback = useCallback(callback, deps); 13 | 14 | useEffect(() => { 15 | hotkeys.filter = () => true; 16 | hotkeys(keys, memoizedCallback); 17 | 18 | return () => hotkeys.unbind(keys); 19 | }, [keys, memoizedCallback]); 20 | } 21 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const plugin = require("tailwindcss/plugin"); 2 | 3 | module.exports = { 4 | content: ["./app/**/*.{ts,tsx,jsx,js}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [ 9 | require("@tailwindcss/line-clamp"), 10 | plugin(({ addVariant }) => { 11 | addVariant("selected", ".selected &"); 12 | addVariant("group-item-hover", ".group-item:hover &"); 13 | addVariant("aria-selected", '[aria-selected="true"] &'); 14 | addVariant("aria-unselected", '[aria-selected="false"] &'); 15 | addVariant("child-mark", "& mark"); 16 | addVariant("hidden", "[hidden]&"); 17 | }), 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress"], 3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "types": ["vitest/globals"], 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "jsx": "react-jsx", 10 | "module": "CommonJS", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "target": "ES2019", 14 | "strict": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"] 18 | }, 19 | "skipLibCheck": true, 20 | "noEmit": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "allowJs": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare global { 6 | var __db__: PrismaClient; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | // in production we'll have a single connection to the DB. 13 | if (process.env.NODE_ENV === "production") { 14 | prisma = new PrismaClient(); 15 | } else { 16 | if (!global.__db__) { 17 | global.__db__ = new PrismaClient(); 18 | } 19 | prisma = global.__db__; 20 | prisma.$connect(); 21 | } 22 | 23 | export { prisma }; 24 | -------------------------------------------------------------------------------- /cypress/support/delete-user.ts: -------------------------------------------------------------------------------- 1 | // Use this to delete a user by their email 2 | // Simply call this with: 3 | // npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com 4 | // and that user will get deleted 5 | 6 | import { installGlobals } from "@remix-run/node/globals"; 7 | import { prisma } from "~/db.server"; 8 | 9 | installGlobals(); 10 | 11 | async function deleteUser(email: string) { 12 | if (!email) { 13 | throw new Error("email required for login"); 14 | } 15 | if (!email.endsWith("@example.com")) { 16 | throw new Error("All test emails must end in @example.com"); 17 | } 18 | 19 | await prisma.user.delete({ where: { email } }); 20 | } 21 | 22 | deleteUser(process.argv[2]); 23 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | on: Cypress.PluginEvents, 3 | config: Cypress.PluginConfigOptions 4 | ) => { 5 | const isDev = config.watchForFileChanges; 6 | const port = process.env.PORT ?? (isDev ? "3000" : "8811"); 7 | const configOverrides: Partial = { 8 | baseUrl: `http://localhost:${port}`, 9 | integrationFolder: "cypress/e2e", 10 | video: !process.env.CI, 11 | screenshotOnRunFailure: !process.env.CI, 12 | }; 13 | Object.assign(config, configOverrides); 14 | 15 | // To use this: 16 | // cy.task('log', whateverYouWantInTheTerminal) 17 | on("task", { 18 | log(message) { 19 | console.log(message); 20 | return null; 21 | }, 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "../node_modules/@types/jest", 4 | "../node_modules/@testing-library/jest-dom" 5 | ], 6 | "include": [ 7 | "./index.ts", 8 | "e2e/**/*", 9 | "plugins/**/*", 10 | "support/**/*", 11 | "../node_modules/cypress", 12 | "../node_modules/@testing-library/cypress" 13 | ], 14 | "compilerOptions": { 15 | "baseUrl": ".", 16 | "noEmit": true, 17 | "types": ["node", "cypress", "@testing-library/cypress"], 18 | "esModuleInterop": true, 19 | "jsx": "react", 20 | "moduleResolution": "node", 21 | "target": "es2019", 22 | "strict": true, 23 | "skipLibCheck": true, 24 | "resolveJsonModule": true, 25 | "typeRoots": ["../types", "../node_modules/@types"], 26 | 27 | "paths": { 28 | "~/*": ["../app/*"] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/types/MovieItem.ts: -------------------------------------------------------------------------------- 1 | import type { Actor } from "./Actor"; 2 | import type { Director } from "./Director"; 3 | import type { Video } from "./Video"; 4 | 5 | type Collection = { 6 | id: number; 7 | name: string; 8 | poster_path: string | null; 9 | backdrop_path: string | null; 10 | }; 11 | 12 | export type MovieItem = { 13 | title: string; 14 | original_title: string; 15 | tagline: string; 16 | genres: string[]; 17 | overview: string; 18 | popularity: number; 19 | release_date: number; 20 | cast: Actor[]; 21 | directors: Director[]; 22 | runtime: number; 23 | vote_average: number; 24 | backdrop_path: string; 25 | belongs_to_collection: Collection[]; 26 | budget: number; 27 | original_language: string; 28 | poster_path: string; 29 | spoken_languages: string[]; 30 | status: string; 31 | videos: Video[]; 32 | record_type: "movie"; 33 | }; 34 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderFunction } from "@remix-run/node"; 3 | 4 | import { prisma } from "~/db.server"; 5 | 6 | export const loader: LoaderFunction = async ({ request }) => { 7 | const host = 8 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 9 | 10 | try { 11 | const url = new URL("/", `http://${host}`); 12 | // if we can connect to the database and make a simple query 13 | // and make a HEAD request to ourselves, then we're good. 14 | await Promise.all([ 15 | prisma.user.count(), 16 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 17 | if (!r.ok) return Promise.reject(r); 18 | }), 19 | ]); 20 | return new Response("OK"); 21 | } catch (error: unknown) { 22 | console.log("healthcheck ❌", { error }); 23 | return new Response("ERROR", { status: 500 }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "webflix-3f91" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [ ] 5 | 6 | [experimental] 7 | allowed_public_ports = [ ] 8 | auto_rollback = true 9 | cmd = "start.sh" 10 | entrypoint = "sh" 11 | 12 | [mounts] 13 | source = "data" 14 | destination = "/data" 15 | 16 | [[services]] 17 | internal_port = 8_080 18 | processes = [ "app" ] 19 | protocol = "tcp" 20 | script_checks = [ ] 21 | 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | handlers = [ "http" ] 29 | port = 80 30 | force_https = true 31 | 32 | [[services.ports]] 33 | handlers = [ "tls", "http" ] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | 42 | [[services.http_checks]] 43 | interval = 10_000 44 | grace_period = "5s" 45 | method = "get" 46 | path = "/healthcheck" 47 | protocol = "http" 48 | timeout = 2_000 49 | tls_skip_verify = false 50 | headers = { } 51 | -------------------------------------------------------------------------------- /prisma/migrations/20220307190657_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "email" TEXT NOT NULL, 5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" DATETIME NOT NULL 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Password" ( 11 | "hash" TEXT NOT NULL, 12 | "userId" TEXT NOT NULL, 13 | CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Note" ( 18 | "id" TEXT NOT NULL PRIMARY KEY, 19 | "title" TEXT NOT NULL, 20 | "body" TEXT NOT NULL, 21 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" DATETIME NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 25 | ); 26 | 27 | -- CreateIndex 28 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 29 | 30 | -- CreateIndex 31 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); 32 | -------------------------------------------------------------------------------- /app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import type { User, Note } from "@prisma/client"; 2 | 3 | import { prisma } from "~/db.server"; 4 | 5 | export type { Note } from "@prisma/client"; 6 | 7 | export function getNote({ 8 | id, 9 | userId, 10 | }: Pick & { 11 | userId: User["id"]; 12 | }) { 13 | return prisma.note.findFirst({ 14 | where: { id, userId }, 15 | }); 16 | } 17 | 18 | export function getNoteListItems({ userId }: { userId: User["id"] }) { 19 | return prisma.note.findMany({ 20 | where: { userId }, 21 | select: { id: true, title: true }, 22 | orderBy: { updatedAt: "desc" }, 23 | }); 24 | } 25 | 26 | export function createNote({ 27 | body, 28 | title, 29 | userId, 30 | }: Pick & { 31 | userId: User["id"]; 32 | }) { 33 | return prisma.note.create({ 34 | data: { 35 | title, 36 | body, 37 | user: { 38 | connect: { 39 | id: userId, 40 | }, 41 | }, 42 | }, 43 | }); 44 | } 45 | 46 | export function deleteNote({ 47 | id, 48 | userId, 49 | }: Pick & { userId: User["id"] }) { 50 | return prisma.note.deleteMany({ 51 | where: { id, userId }, 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /app/components/image-with-loader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | import { cx } from "~/utils"; 4 | 5 | type ImageWithLoaderFallbackProps = { 6 | isLoading: boolean; 7 | }; 8 | 9 | type ImageWithLoaderProps = React.ComponentProps<"img"> & { 10 | fallback({ isLoading }: ImageWithLoaderFallbackProps): React.ReactNode; 11 | }; 12 | 13 | export function ImageWithLoader({ fallback, ...props }: ImageWithLoaderProps) { 14 | const [isLoading, setIsLoading] = useState(true); 15 | const imgRef = useRef(null); 16 | 17 | useEffect(() => { 18 | setIsLoading(true); 19 | }, [props.src]); 20 | 21 | useEffect(() => { 22 | if (imgRef.current?.complete) { 23 | setIsLoading(false); 24 | } 25 | }, []); 26 | 27 | return ( 28 | <> 29 | {/* `alt` is forwarded within `props` */} 30 | {/* eslint-disable jsx-a11y/alt-text */} 31 | { 35 | props.onLoad?.(event); 36 | setIsLoading(false); 37 | }} 38 | ref={imgRef} 39 | /> 40 | {fallback({ isLoading })} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/components/video-with-preview.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { cx } from "~/utils"; 4 | 5 | type VideoStatus = "idle" | "loading" | "loaded"; 6 | 7 | type VideoWithPreviewPreviewProps = { 8 | status: VideoStatus; 9 | load: () => void; 10 | }; 11 | 12 | type VideoWithPreviewProps = React.ComponentProps<"iframe"> & { 13 | preview({ status }: VideoWithPreviewPreviewProps): React.ReactNode; 14 | }; 15 | 16 | export function VideoWithPreview({ preview, ...props }: VideoWithPreviewProps) { 17 | const [status, setStatus] = useState("idle"); 18 | 19 | function load() { 20 | setStatus("loading"); 21 | } 22 | 23 | useEffect(() => { 24 | setStatus("idle"); 25 | }, [props.src]); 26 | 27 | return ( 28 | <> 29 | {["loading", "loaded"].includes(status) && ( 30 | /* `title` is forwarded within `props` */ 31 | /* eslint-disable jsx-a11y/iframe-has-title */ 32 |