├── 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 |
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 |
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 |
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 |
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 |