├── .gitignore
├── public
├── icon.png
├── icon-192.png
├── icon-512.png
├── icon-apple.png
├── manifest.json
└── icon.svg
├── remix.env.d.ts
├── tailwind.config.js
├── .eslintrc.js
├── .env
├── app
├── entry.client.tsx
├── components
│ ├── avatar.tsx
│ ├── footer.tsx
│ ├── outerGrid.tsx
│ ├── errorBanner.tsx
│ ├── movieThumbnail.tsx
│ ├── header.tsx
│ ├── movieReviewItem.tsx
│ ├── recommendedMovies.tsx
│ ├── ratingInput.tsx
│ ├── movieReviewForm.tsx
│ ├── movieReviewsList.tsx
│ └── navbar.tsx
├── utils
│ └── getOptimisticData.ts
├── routes
│ ├── sign-out.tsx
│ ├── _grid.series.tsx
│ ├── _grid.movies._index.tsx
│ ├── _grid.tsx
│ ├── _grid._index.tsx
│ ├── _grid.sign-in.tsx
│ ├── _grid.movies.$slug.tsx
│ └── movies.$slug.watch.tsx
├── session.ts
├── root.tsx
├── types.ts
├── hooks
│ ├── useRequest.ts
│ └── useQuery.ts
├── tailwind.css
├── images
│ └── logo.svg
└── entry.server.tsx
├── remix.config.js
├── tsconfig.json
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/build
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kettanaito/movie-app/HEAD/public/icon.png
--------------------------------------------------------------------------------
/public/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kettanaito/movie-app/HEAD/public/icon-192.png
--------------------------------------------------------------------------------
/public/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kettanaito/movie-app/HEAD/public/icon-512.png
--------------------------------------------------------------------------------
/public/icon-apple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kettanaito/movie-app/HEAD/public/icon-apple.png
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./app/**/*.{ts,tsx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4 | rules: {
5 | 'react-hooks/exhaustive-deps': 'off',
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # This environment variables file is intentionally public
2 | # for the sake of the exercises.
3 | # In a real application, DO NOT commit your ".env" files
4 | # to Git. Instead, keep them git-ignored.
5 | SESSION_COOKIE_SECRET=super-secret
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from '@remix-run/react'
2 | import { startTransition } from 'react'
3 | import { hydrateRoot } from 'react-dom/client'
4 |
5 | startTransition(() => {
6 | hydrateRoot(document, )
7 | })
8 |
--------------------------------------------------------------------------------
/app/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | export function Avatar({ url, alt }: { url: string; alt: string }) {
2 | return (
3 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/app/components/footer.tsx:
--------------------------------------------------------------------------------
1 | export function Footer() {
2 | return (
3 |
4 |
5 | © 2023 Movie App.
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "icons": [
3 | {
4 | "src": "/icon-192.png",
5 | "type": "image/png",
6 | "sizes": "192x192"
7 | },
8 | {
9 | "src": "/icon-512.png",
10 | "type": "image/png",
11 | "sizes": "512x512"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | module.exports = {
3 | ignoredRouteFiles: ['**/.*'],
4 | serverModuleFormat: 'cjs',
5 | // appDirectory: "app",
6 | // assetsBuildDirectory: "public/build",
7 | // serverBuildPath: "build/index.js",
8 | // publicPath: "/build/",
9 | tailwind: true,
10 | }
11 |
--------------------------------------------------------------------------------
/app/components/outerGrid.tsx:
--------------------------------------------------------------------------------
1 | export function OuterGrid({
2 | children,
3 | className,
4 | }: {
5 | children: React.ReactNode
6 | className?: string
7 | }) {
8 | return (
9 |
12 | {children}
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/app/utils/getOptimisticData.ts:
--------------------------------------------------------------------------------
1 | import { type Fetcher } from '@remix-run/react'
2 |
3 | export function getOptimisticData(
4 | fetcher?: Fetcher,
5 | ): Data | undefined {
6 | if (typeof fetcher === 'undefined') {
7 | return
8 | }
9 |
10 | if (fetcher.state === 'idle') {
11 | return fetcher.data
12 | }
13 |
14 | return
15 | }
16 |
--------------------------------------------------------------------------------
/app/routes/sign-out.tsx:
--------------------------------------------------------------------------------
1 | import { redirect, type ActionArgs } from '@remix-run/node'
2 | import { destroySession, getSession } from '~/session'
3 |
4 | export async function action({ request }: ActionArgs) {
5 | const session = await getSession(request.headers.get('Cookie'))
6 |
7 | return redirect('/sign-in', {
8 | headers: {
9 | 'Set-Cookie': await destroySession(session),
10 | },
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | icon
4 |
5 |
6 | 🍿
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/routes/_grid.series.tsx:
--------------------------------------------------------------------------------
1 | import { type MetaFunction } from '@remix-run/node'
2 |
3 | export const meta: MetaFunction = () => {
4 | return [{ title: 'Series - Movies App' }]
5 | }
6 |
7 | export default function SeriesPage() {
8 | return (
9 |
10 |
TV Series
11 |
12 | Apply what you learn from this course to implement a "TV Series" page
13 | how you see fit!
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/errorBanner.tsx:
--------------------------------------------------------------------------------
1 | interface ErrorBannerProps {
2 | displayText: string
3 | error: Error
4 | }
5 |
6 | export function ErrorBanner({ displayText, error }: ErrorBannerProps) {
7 | return (
8 |
9 |
10 | {displayText}
11 |
12 |
13 | {error.name}: {error.message}
14 |
15 |
16 | Report an issue
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "module": "esnext",
12 | "strict": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "baseUrl": ".",
16 | "paths": {
17 | "~/*": ["./app/*"]
18 | },
19 |
20 | // Remix takes care of building everything in `remix build`.
21 | "noEmit": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/routes/_grid.movies._index.tsx:
--------------------------------------------------------------------------------
1 | import { type MetaFunction } from '@remix-run/node'
2 |
3 | export const meta: MetaFunction = () => {
4 | return [{ title: 'Movies - Movies App' }]
5 | }
6 |
7 | export default function MoviesPage() {
8 | return (
9 |
10 |
Movies
11 |
12 | Practice what you've learned and implement a request handler that
13 | returns a list of all existing movies on this page. Reuse the same{' '}
14 | movies array for data and the MovieThumbnail{' '}
15 | component for the UI.
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/app/session.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 | import type { User } from './types'
3 |
4 | type SessionData = {
5 | user: User
6 | }
7 |
8 | type SessionFlashData = {
9 | error: string
10 | }
11 |
12 | const { getSession, commitSession, destroySession } =
13 | createCookieSessionStorage({
14 | cookie: {
15 | name: '__session',
16 | httpOnly: true,
17 | path: '/',
18 | sameSite: 'lax',
19 | secure: true,
20 | secrets: [process.env.SESSION_COOKIE_SECRET!],
21 | },
22 | })
23 |
24 | export async function requireAuthenticatedUser(
25 | request: Request,
26 | ): Promise {
27 | const session = await getSession(request.headers.get('Cookie'))
28 | const user = session.get('user')
29 |
30 | if (!user) {
31 | throw new Response(null, { status: 401 })
32 | }
33 |
34 | return user
35 | }
36 |
37 | export { getSession, commitSession, destroySession }
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "sideEffects": false,
4 | "scripts": {
5 | "build": "remix build",
6 | "dev": "remix dev",
7 | "start": "remix-serve build",
8 | "typecheck": "tsc"
9 | },
10 | "dependencies": {
11 | "@remix-run/node": "^0.0.0-nightly-ebc148c-20230824",
12 | "@remix-run/react": "^0.0.0-nightly-ebc148c-20230824",
13 | "@remix-run/serve": "^0.0.0-nightly-ebc148c-20230824",
14 | "graphql": "^16.7.1",
15 | "graphql-request": "^6.1.0",
16 | "isbot": "^3.6.8",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-icons": "^4.8.0"
20 | },
21 | "devDependencies": {
22 | "@remix-run/dev": "^0.0.0-nightly-ebc148c-20230824",
23 | "@remix-run/eslint-config": "^0.0.0-nightly-ebc148c-20230824",
24 | "@types/react": "^18.0.35",
25 | "@types/react-dom": "^18.0.11",
26 | "eslint": "^8.38.0",
27 | "tailwindcss": "^3.3.1",
28 | "typescript": "^5.1.6"
29 | },
30 | "engines": {
31 | "node": ">=18"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/movieThumbnail.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react'
2 |
3 | interface MovieThumbnailProps {
4 | title: string
5 | url: string
6 | category: string
7 | imageUrl: string
8 | releasedAt: Date
9 | }
10 |
11 | export function MovieThumbnail({
12 | title,
13 | url,
14 | category,
15 | imageUrl,
16 | releasedAt,
17 | }: MovieThumbnailProps) {
18 | return (
19 |
20 |
21 |
22 |
27 |
28 |
29 |
30 |
34 | {title}
35 |
36 |
37 | {category} • {releasedAt.getFullYear()}
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/app/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react'
2 | import { HiMagnifyingGlass as SearchIcon } from 'react-icons/hi2'
3 | import { OuterGrid } from './outerGrid'
4 | import { Container } from './container'
5 |
6 | export function Header() {
7 | return (
8 |
9 |
10 |
14 | 🍿
15 |
16 |
17 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/components/movieReviewItem.tsx:
--------------------------------------------------------------------------------
1 | import type { MovieReview } from '~/types'
2 | import { HiStar as StarIcon } from 'react-icons/hi2'
3 | import { Avatar } from './avatar'
4 |
5 | interface MovieReviewItemProps {
6 | review: MovieReview
7 | }
8 |
9 | export function MovieReviewItem({ review }: MovieReviewItemProps) {
10 | return (
11 |
12 |
13 |
14 |
15 | {review.author.firstName}
16 |
17 |
18 |
19 | {review.rating}
20 |
21 | / 5
22 |
23 |
24 |
25 |
{review.text}
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { type LinksFunction } from '@remix-run/node'
2 | import {
3 | Links,
4 | LiveReload,
5 | Meta,
6 | Outlet,
7 | Scripts,
8 | ScrollRestoration,
9 | } from '@remix-run/react'
10 | import stylesheet from '~/tailwind.css'
11 |
12 | export const links: LinksFunction = () => {
13 | return [
14 | { rel: 'icon', type: 'image/png', sizes: 'any', href: '/icon.png' },
15 | { rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' },
16 | { rel: 'apple-touch-icon', href: '/icon-apple.png' },
17 | { rel: 'manifest', href: '/manifest.json' },
18 | {
19 | rel: 'stylesheet',
20 | href: stylesheet,
21 | },
22 | ]
23 | }
24 |
25 | export default function App() {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Mock REST and GraphQL APIs with Mock Service Worker
2 |
3 | ## System requirements
4 |
5 | - [Node.js](https://nodejs.org/) `>=18`
6 | - [npm](https://www.npmjs.com/) `>=v8.16.0`
7 |
8 | To verify those tools and their versions, you can run this:
9 |
10 | ```sh
11 | node --version
12 | npm --version
13 | ```
14 |
15 | ## Setup
16 |
17 | You can use this repository to follow along the lessons. If you wish so, start by cloning the repository:
18 |
19 | ```sh
20 | git clone https://github.com/kettanaito/movie-app
21 | cd movie-app
22 | npm install
23 | ```
24 |
25 | Once you're set, start the application by running this command:
26 |
27 | ```sh
28 | npm run dev
29 | ```
30 |
31 | ### Viewing the complete course
32 |
33 | You can checkout the `completed` branch to view the state of the application at the end of the course. Feel free to use that as a reference or as a complete example.
34 |
35 | ```sh
36 | git checkout completed
37 | ```
38 |
39 | ## Questions & feedback
40 |
41 | If you have any questions while working through the course, or would like to share any feedback, please do so in my [**Discord server**](https://discord.gg/z29WbnfDC5).
42 |
--------------------------------------------------------------------------------
/app/routes/_grid.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/node'
2 | import { Outlet, type MetaFunction } from '@remix-run/react'
3 | import { Header } from '~/components/header'
4 | import { OuterGrid } from '~/components/outerGrid'
5 | import { Navbar } from '~/components/navbar'
6 | import { Footer } from '~/components/footer'
7 | import { getSession } from '~/session'
8 |
9 | export const meta: MetaFunction = () => {
10 | return [
11 | {
12 | title: 'Movies App',
13 | },
14 | ]
15 | }
16 |
17 | export async function loader({ request }: LoaderArgs) {
18 | const session = await getSession(request.headers.get('Cookie'))
19 | const user = session.get('user')
20 |
21 | return {
22 | user,
23 | }
24 | }
25 |
26 | export default function GridLayout() {
27 | return (
28 | <>
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | >
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/app/types.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: string
3 | email: string
4 | firstName: string
5 | lastName: string
6 | avatarUrl: string
7 | }
8 |
9 | export interface Movie {
10 | id: string
11 | slug: string
12 | title: string
13 | category: string
14 | releasedAt: Date
15 | description: string
16 | imageUrl: string
17 | }
18 |
19 | export interface FeaturedMovie extends Movie {}
20 |
21 | export interface MovieReview {
22 | id: string
23 | text: string
24 | rating: number
25 | author: User
26 | }
27 |
28 | export interface ListReviewsQuery {
29 | reviews: Array
30 | }
31 |
32 | export interface ListReviewQueryVariables {
33 | movieId: string
34 | }
35 |
36 | export interface AddReviewMutation {
37 | addReview: {
38 | id: string
39 | text: string
40 | }
41 | }
42 |
43 | export interface AddReviewMutationVariables {
44 | author: Pick
45 | reviewInput: {
46 | movieId: string
47 | text: string
48 | rating: number
49 | }
50 | }
51 |
52 | export interface Series {
53 | id: string
54 | title: string
55 | category: string
56 | episodes: Array
57 | }
58 |
59 | export interface Episode {
60 | id: string
61 | title: string
62 | durationMinutes: number
63 | }
64 |
--------------------------------------------------------------------------------
/app/hooks/useRequest.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | type RequestUnion =
4 | | {
5 | state: 'idle'
6 | data: null
7 | error: null
8 | }
9 | | {
10 | state: 'loading'
11 | data: null
12 | error: null
13 | }
14 | | {
15 | state: 'done'
16 | data: Data
17 | error: null
18 | }
19 | | {
20 | state: 'done'
21 | data: null
22 | error: Error
23 | }
24 |
25 | export function useRequest(
26 | info: RequestInfo,
27 | init?: RequestInit,
28 | ): RequestUnion {
29 | const [state, setState] = useState>({
30 | state: 'idle',
31 | data: null,
32 | error: null,
33 | })
34 |
35 | useEffect(() => {
36 | setState({ state: 'idle', data: null, error: null })
37 |
38 | fetch(info, init)
39 | .then((response) => {
40 | if (!response.ok) {
41 | throw new TypeError(
42 | `Failed to fetch: server responded with ${response.status}.`,
43 | )
44 | }
45 |
46 | return response.json()
47 | })
48 | .then((data) => {
49 | setState({ state: 'done', data, error: null })
50 | })
51 | .catch((error) => {
52 | setState({ state: 'done', data: null, error })
53 | })
54 | }, [])
55 |
56 | return state
57 | }
58 |
--------------------------------------------------------------------------------
/app/routes/_grid._index.tsx:
--------------------------------------------------------------------------------
1 | import { useLoaderData, useRouteError } from '@remix-run/react'
2 | import { ErrorBanner } from '~/components/errorBanner'
3 | import { MovieThumbnail } from '~/components/movieThumbnail'
4 | import type { FeaturedMovie } from '~/types'
5 |
6 | export async function loader() {
7 | const featuredMovies = await fetch(
8 | 'https://api.example.com/movies/featured',
9 | ).then>((response) => response.json())
10 |
11 | return {
12 | featuredMovies,
13 | }
14 | }
15 |
16 | export default function Homepage() {
17 | const { featuredMovies } = useLoaderData()
18 |
19 | return (
20 |
21 | Featured movies
22 | {featuredMovies.length > 0 ? (
23 |
24 | {featuredMovies.map((movie) => (
25 |
26 |
33 |
34 | ))}
35 |
36 | ) : (
37 | No featured movies yet.
38 | )}
39 |
40 | )
41 | }
42 |
43 | export function ErrorBoundary() {
44 | const error = useRouteError() as Error
45 |
46 | return
47 | }
48 |
--------------------------------------------------------------------------------
/app/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 |
9 | @apply h-full;
10 | }
11 |
12 | body {
13 | @apply flex flex-col h-full bg-neutral-900 text-neutral-200;
14 | }
15 |
16 | button,
17 | .button {
18 | @apply bg-neutral-700 text-neutral-100 px-4 py-2.5 font-bold text-center rounded-md;
19 | @apply hover:bg-neutral-600;
20 | @apply outline-0 focus:ring-4;
21 | }
22 |
23 | button:focus {
24 | @apply outline-none focus:ring-4 focus:border-blue-500;
25 | }
26 |
27 | .button-primary {
28 | @apply bg-blue-600 text-white;
29 | @apply hover:bg-blue-700;
30 | }
31 |
32 | .button-ghost {
33 | @apply bg-transparent text-neutral-100 border border-white border-opacity-30;
34 | @apply hover:bg-white hover:bg-opacity-10;
35 | }
36 |
37 | input,
38 | textarea {
39 | @apply border border-white border-opacity-20 bg-transparent px-3 py-2 text-white rounded-md;
40 | resize: none;
41 | }
42 |
43 | input::placeholder,
44 | textarea::placeholder {
45 | @apply text-neutral-600;
46 | }
47 |
48 | input[type='range'] {
49 | @apply px-0;
50 | }
51 |
52 | input:focus,
53 | textarea:focus {
54 | @apply outline-none focus:ring-4 focus:border-blue-500;
55 | }
56 |
57 | .animate-placeholder {
58 | @apply relative overflow-hidden;
59 | }
60 | .animate-placeholder::after {
61 | content: '';
62 | @apply absolute inset-0 bg-gradient-to-b from-transparent via-white to-transparent opacity-5;
63 | animation: placeholderShift infinite 1.5s linear;
64 | transform: rotate(-45deg);
65 | width: 100%;
66 | }
67 |
68 | @keyframes placeholderShift {
69 | from {
70 | left: -100%;
71 | }
72 | to {
73 | left: 100%;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/components/recommendedMovies.tsx:
--------------------------------------------------------------------------------
1 | import { MovieThumbnail } from './movieThumbnail'
2 | import { ErrorBanner } from './errorBanner'
3 | import { useRequest } from '~/hooks/useRequest'
4 | import type { Movie } from '~/types'
5 |
6 | export function RecommendedMovies() {
7 | const { state, error, data } =
8 | useRequest>(`/api/recommendations`)
9 |
10 | if (state === 'idle' || (state === 'done' && data == null && error == null)) {
11 | return null
12 | }
13 |
14 | return (
15 |
16 | Recommended
17 | {state === 'loading' ? (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | ) : error ? (
27 |
28 | ) : data?.length > 0 ? (
29 |
30 | {data?.map((movie) => (
31 |
32 |
39 |
40 | ))}
41 |
42 | ) : (
43 | No recommendations found.
44 | )}
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/app/components/ratingInput.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import {
3 | HiOutlineStar as StartIcon,
4 | HiStar as StarFillIcon,
5 | } from 'react-icons/hi2'
6 |
7 | interface RatingInputProps {
8 | name: string
9 | label: string
10 | defaultValue?: number
11 | value?: number
12 | onChange?: (nextRating: number) => void
13 | }
14 |
15 | export function RatingInput(props: RatingInputProps) {
16 | const [internalValue, setInternalValue] = useState(props.value ?? 0)
17 | const value = props.value == null ? internalValue : props.value
18 |
19 | const starsCount = 5
20 |
21 | const handleRatingChange = (nextRating: number) => {
22 | if (props.value != null) {
23 | return props.onChange?.(nextRating)
24 | }
25 |
26 | setInternalValue(nextRating)
27 | }
28 |
29 | const handleReset: React.FormEventHandler = (event) => {
30 | const nextValue = Number(event.currentTarget.value)
31 | setInternalValue(nextValue)
32 | }
33 |
34 | return (
35 |
36 |
{}}
44 | onReset={handleReset}
45 | />
46 |
47 |
{props.label}:
48 |
49 | {Array.from({ length: starsCount }, (_, index) => {
50 | const isFilled = index < value
51 | const currentStartValue = index + 1
52 |
53 | if (isFilled) {
54 | return (
55 |
60 | )
61 | }
62 |
63 | return (
64 |
68 | )
69 | })}
70 |
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/app/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | logo
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/components/movieReviewForm.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { Link, useFetcher, useLoaderData, useLocation } from '@remix-run/react'
3 | import { RatingInput } from './ratingInput'
4 | import type { loader } from '../routes/_grid.movies.$slug'
5 |
6 | export function MovieReviewForm({ movieId }: { movieId: string }) {
7 | const location = useLocation()
8 | const { isAuthenticated } = useLoaderData()
9 | const fetcher = useFetcher()
10 | const [rating, setRating] = useState(0)
11 | const [reviewText, setReviewText] = useState('')
12 |
13 | useEffect(() => {
14 | if (fetcher.state === 'idle' && fetcher.data?.id) {
15 | setRating(0)
16 | setReviewText('')
17 | }
18 | }, [fetcher.state, fetcher.data])
19 |
20 | return (
21 |
22 |
23 |
Create new review
24 | {isAuthenticated ? (
25 |
30 |
31 |
32 |
33 | setRating(nextRating)}
38 | />
39 |
50 | ) : (
51 |
52 |
53 | Please sign in to review this movie.
54 |
55 |
56 | )}
57 |
58 | {!isAuthenticated ? (
59 |
64 | Sign in
65 |
66 | ) : null}
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/app/components/movieReviewsList.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useFetchers } from '@remix-run/react'
3 | import { gql } from 'graphql-request'
4 | import { useQuery } from '~/hooks/useQuery'
5 | import type {
6 | ListReviewQueryVariables,
7 | ListReviewsQuery,
8 | MovieReview,
9 | } from '~/types'
10 | import { getOptimisticData } from '~/utils/getOptimisticData'
11 | import { MovieReviewItem } from './movieReviewItem'
12 | import { MovieReviewForm } from './movieReviewForm'
13 | import { ErrorBanner } from './errorBanner'
14 |
15 | interface MovieReviewListProps {
16 | movieId: string
17 | }
18 |
19 | export function MovieReviewsList({ movieId }: MovieReviewListProps) {
20 | const [{ loading, data, error }, { updateCache }] = useQuery<
21 | ListReviewsQuery,
22 | ListReviewQueryVariables
23 | >(
24 | gql`
25 | query ListReviews($movieId: ID!) {
26 | reviews(movieId: $movieId) {
27 | id
28 | text
29 | rating
30 | author {
31 | firstName
32 | avatarUrl
33 | }
34 | }
35 | }
36 | `,
37 | {
38 | variables: {
39 | movieId,
40 | },
41 | },
42 | )
43 |
44 | const fetchers = useFetchers()
45 | const pendingReview = getOptimisticData(fetchers[0])
46 |
47 | useEffect(() => {
48 | if (pendingReview) {
49 | updateCache((cache) => {
50 | cache.reviews.push(pendingReview)
51 | return cache
52 | })
53 | }
54 | }, [pendingReview])
55 |
56 | return (
57 |
58 |
59 |
Reviews
60 | {loading ? (
61 |
65 | ) : error ? (
66 |
67 | ) : data?.reviews?.length ? (
68 |
69 | {data?.reviews.map((review) => (
70 |
71 |
72 |
73 | ))}
74 |
75 | ) : (
76 |
No reviews for this movie yet.
77 | )}
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/app/routes/_grid.sign-in.tsx:
--------------------------------------------------------------------------------
1 | import { type MetaFunction, Form, useLocation } from '@remix-run/react'
2 | import { redirect, type ActionArgs } from '@remix-run/node'
3 | import { commitSession, getSession } from '~/session'
4 |
5 | export const meta: MetaFunction = () => {
6 | return [
7 | {
8 | title: 'Sign in - Movie App',
9 | },
10 | ]
11 | }
12 |
13 | export async function action({ request }: ActionArgs) {
14 | const session = await getSession(request.headers.get('Cookie'))
15 |
16 | // If authenticated, redirect to the homepage.
17 | if (session.has('user')) {
18 | return redirect('/')
19 | }
20 |
21 | const data = await request.formData()
22 | const redirectUrl = (data.get('redirectUrl') as string) || '/'
23 |
24 | // Validate the user credentials.
25 | const user = await fetch('https://auth.provider.com/validate', {
26 | method: 'POST',
27 | body: data,
28 | }).then(
29 | (response) => response.json(),
30 | () => null,
31 | )
32 |
33 | if (user != null) {
34 | session.set('user', user)
35 | }
36 |
37 | if (!session.has('user')) {
38 | session.flash('error', 'Failed to sign in: invalid credentials.')
39 | }
40 |
41 | // Forward any sign-in errors to the client.
42 | if (!session.has('user')) {
43 | return redirect('/sign-in', {
44 | headers: {
45 | 'Set-Cookie': await commitSession(session),
46 | },
47 | })
48 | }
49 |
50 | // Sign in the user and redirect to the homepage.
51 | return redirect(redirectUrl, {
52 | headers: {
53 | 'Set-Cookie': await commitSession(session),
54 | },
55 | })
56 | }
57 |
58 | export default function SignInPage() {
59 | const { state } = useLocation()
60 |
61 | return (
62 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/app/hooks/useQuery.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { request } from 'graphql-request'
3 |
4 | interface QueryOptions> {
5 | variables?: VariablesType
6 | }
7 |
8 | type QueryState> =
9 | | {
10 | loading: true
11 | data: null
12 | error: null
13 | }
14 | | {
15 | loading: false
16 | data: QueryType
17 | error: null
18 | }
19 | | {
20 | loading: false
21 | data: null
22 | error: Error
23 | }
24 |
25 | export function useQuery<
26 | QueryType extends Record,
27 | VariablesType extends Record = {},
28 | >(
29 | query: string,
30 | options?: QueryOptions,
31 | ): [
32 | QueryState,
33 | {
34 | updateCache: (updateFn: (data: QueryType) => QueryType) => void
35 | },
36 | ] {
37 | const [state, setState] = useState>({
38 | loading: true,
39 | data: null,
40 | error: null,
41 | })
42 |
43 | useEffect(() => {
44 | request({
45 | url: 'http://localhost:3000',
46 | document: query,
47 | variables: options?.variables,
48 | }).then(
49 | (data) => {
50 | setState({
51 | loading: false,
52 | data,
53 | error: null,
54 | })
55 | },
56 | (error) => {
57 | setState({
58 | loading: false,
59 | error: error.response.errors[0],
60 | data: null,
61 | })
62 | },
63 | )
64 | }, [])
65 |
66 | return [
67 | state,
68 | {
69 | updateCache(updateFn) {
70 | if (!state.loading && !state.error) {
71 | setState({ ...state, data: updateFn(state.data) })
72 | }
73 | },
74 | },
75 | ]
76 | }
77 |
78 | interface MutationOptions> {
79 | variables?: VariablesType
80 | }
81 |
82 | type MutationState> =
83 | | {
84 | data: MutationType
85 | error: null
86 | }
87 | | {
88 | data: null
89 | error: Error
90 | }
91 |
92 | export function mutate<
93 | MutationType extends Record,
94 | VariablesType extends Record = {},
95 | >(mutation: string) {
96 | return (
97 | options?: MutationOptions,
98 | ): Promise> => {
99 | return request({
100 | url: 'http://localhost:3000',
101 | document: mutation,
102 | variables: options?.variables,
103 | }).then(
104 | (data) => ({ data, error: null }),
105 | (error) => ({ data: null, error }),
106 | )
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/app/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Form,
3 | NavLink,
4 | useLoaderData,
5 | type NavLinkProps,
6 | } from '@remix-run/react'
7 | import type { IconType } from 'react-icons'
8 | import {
9 | HiHome as HomeIcon,
10 | HiVideoCamera as CameraIcon,
11 | HiTicket as TicketIcon,
12 | HiArrowLeftOnRectangle as SignInIcon,
13 | HiArrowRightOnRectangle as SignOutIcon,
14 | } from 'react-icons/hi2'
15 | import { Avatar } from './avatar'
16 | import type { loader } from '../routes/_grid'
17 |
18 | export function Navbar() {
19 | const { user } = useLoaderData()
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | Home
27 |
28 |
29 |
30 |
31 | Movies
32 |
33 |
34 |
35 |
36 | TV series
37 |
38 |
39 |
40 |
41 |
42 | {user ? (
43 |
44 |
45 |
46 |
47 | {user.firstName} {user.lastName}
48 |
49 |
50 |
59 |
60 | ) : (
61 |
62 | Sign in
63 |
64 | )}
65 |
66 |
67 | )
68 | }
69 |
70 | interface NavBarLinkProps extends NavLinkProps {
71 | icon: IconType
72 | }
73 |
74 | function NavBarLink({ icon: Icon, ...props }: NavBarLinkProps) {
75 | return (
76 |
79 | [
80 | 'flex items-center gap-4 px-4 py-3 font-bold rounded-md',
81 | 'outline-0 focus:ring-4',
82 | isActive
83 | ? 'bg-stone-700 text-white'
84 | : 'hover:bg-neutral-800 hover:text-white',
85 | ]
86 | .filter(Boolean)
87 | .join(' ')
88 | }
89 | >
90 | <>
91 |
92 |
93 | <>{props.children}>
94 |
95 | >
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from 'node:stream'
2 | import type { EntryContext } from '@remix-run/node'
3 | import { Response } from '@remix-run/node'
4 | import { RemixServer } from '@remix-run/react'
5 | import isbot from 'isbot'
6 | import { renderToPipeableStream } from 'react-dom/server'
7 |
8 | const ABORT_DELAY = 5_000
9 |
10 | export default function handleRequest(
11 | request: Request,
12 | responseStatusCode: number,
13 | responseHeaders: Headers,
14 | remixContext: EntryContext,
15 | ) {
16 | return isbot(request.headers.get('user-agent'))
17 | ? handleBotRequest(
18 | request,
19 | responseStatusCode,
20 | responseHeaders,
21 | remixContext,
22 | )
23 | : handleBrowserRequest(
24 | request,
25 | responseStatusCode,
26 | responseHeaders,
27 | remixContext,
28 | )
29 | }
30 |
31 | function handleBotRequest(
32 | request: Request,
33 | responseStatusCode: number,
34 | responseHeaders: Headers,
35 | remixContext: EntryContext,
36 | ) {
37 | return new Promise((resolve, reject) => {
38 | const { pipe, abort } = renderToPipeableStream(
39 | ,
44 | {
45 | onAllReady() {
46 | const body = new PassThrough()
47 |
48 | responseHeaders.set('Content-Type', 'text/html')
49 |
50 | resolve(
51 | new Response(body, {
52 | headers: responseHeaders,
53 | status: responseStatusCode,
54 | }),
55 | )
56 |
57 | pipe(body)
58 | },
59 | onShellError(error: unknown) {
60 | reject(error)
61 | },
62 | onError(error: unknown) {
63 | responseStatusCode = 500
64 | console.error(error)
65 | },
66 | },
67 | )
68 |
69 | setTimeout(abort, ABORT_DELAY)
70 | })
71 | }
72 |
73 | function handleBrowserRequest(
74 | request: Request,
75 | responseStatusCode: number,
76 | responseHeaders: Headers,
77 | remixContext: EntryContext,
78 | ) {
79 | return new Promise((resolve, reject) => {
80 | const { pipe, abort } = renderToPipeableStream(
81 | ,
86 | {
87 | onShellReady() {
88 | const body = new PassThrough()
89 |
90 | responseHeaders.set('Content-Type', 'text/html')
91 |
92 | resolve(
93 | new Response(body, {
94 | headers: responseHeaders,
95 | status: responseStatusCode,
96 | }),
97 | )
98 |
99 | pipe(body)
100 | },
101 | onShellError(error: unknown) {
102 | reject(error)
103 | },
104 | onError(error: unknown) {
105 | console.error(error)
106 | responseStatusCode = 500
107 | },
108 | },
109 | )
110 |
111 | setTimeout(abort, ABORT_DELAY)
112 | })
113 | }
114 |
--------------------------------------------------------------------------------
/app/routes/_grid.movies.$slug.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | redirect,
3 | type MetaFunction,
4 | type LoaderArgs,
5 | type ActionArgs,
6 | json,
7 | } from '@remix-run/node'
8 | import { useLoaderData, Link } from '@remix-run/react'
9 | import { gql } from 'graphql-request'
10 | import { HiPlayCircle as PlayIcon } from 'react-icons/hi2'
11 | import { mutate } from '~/hooks/useQuery'
12 | import type {
13 | AddReviewMutation,
14 | AddReviewMutationVariables,
15 | Movie,
16 | } from '~/types'
17 | import { MovieReviewsList } from '~/components/movieReviewsList'
18 | import { getSession, requireAuthenticatedUser } from '~/session'
19 | import { RecommendedMovies } from '~/components/recommendedMovies'
20 |
21 | export async function loader({ request, params }: LoaderArgs) {
22 | const session = await getSession(request.headers.get('Cookie'))
23 | const user = session.get('user')
24 |
25 | const { slug } = params
26 |
27 | if (typeof slug === 'undefined') {
28 | throw redirect('/movies')
29 | }
30 |
31 | const response = await fetch(
32 | // Fetch a single movie detail by its slug.
33 | `https://api.example.com/movies/${slug}`,
34 | ).catch((error) => {
35 | throw redirect('/movies')
36 | })
37 |
38 | if (!response.ok) {
39 | throw redirect('/movies')
40 | }
41 |
42 | const movie = (await response.json()) satisfies Movie
43 |
44 | return {
45 | movie,
46 | isAuthenticated: user != null,
47 | }
48 | }
49 |
50 | export const meta: MetaFunction = ({ data }) => {
51 | return [
52 | {
53 | title: `${data.movie.title} - Movie App`,
54 | },
55 | ]
56 | }
57 |
58 | export async function action({ request }: ActionArgs) {
59 | const user = await requireAuthenticatedUser(request)
60 | const payload = await request.formData()
61 |
62 | const movieId = payload.get('movieId') as string
63 | const reviewRating = Number(payload.get('reviewRating'))
64 | const reviewText = payload.get('reviewText') as string
65 |
66 | // Post a GraphQL mutation to submit a review.
67 | const addReview = mutate(gql`
68 | mutation AddReview($author: UserInput!, $reviewInput: ReviewInput!) {
69 | addReview(author: $author, reviewInput: $reviewInput) {
70 | id
71 | text
72 | author {
73 | id
74 | firstName
75 | avatarUrl
76 | }
77 | }
78 | }
79 | `)
80 |
81 | const addReviewResponse = await addReview({
82 | variables: {
83 | author: {
84 | id: user.id,
85 | firstName: user.firstName,
86 | avatarUrl: user.avatarUrl,
87 | },
88 | reviewInput: {
89 | movieId,
90 | text: reviewText,
91 | rating: reviewRating,
92 | },
93 | },
94 | })
95 |
96 | if (addReviewResponse.error) {
97 | throw addReviewResponse.error
98 | }
99 |
100 | return json(addReviewResponse.data.addReview)
101 | }
102 |
103 | export default function MovieDetailPage() {
104 | const { movie } = useLoaderData()
105 |
106 | return (
107 |
108 |
109 |
114 |
115 |
119 |
120 | Watch now
121 |
122 |
123 |
124 |
125 |
126 |
{movie.title}
127 |
128 | {movie.category} • {new Date(movie.releasedAt).getFullYear()}
129 |
130 |
{movie.description}
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/app/routes/movies.$slug.watch.tsx:
--------------------------------------------------------------------------------
1 | import { type LoaderArgs, type MetaArgs, redirect } from '@remix-run/node'
2 | import { Link, useLoaderData, useNavigate } from '@remix-run/react'
3 | import { useEffect, useRef, useState } from 'react'
4 | import {
5 | HiXCircle as CloseIcon,
6 | HiPlay as PlayIcon,
7 | HiPause as PauseIcon,
8 | } from 'react-icons/hi2'
9 | import type { Movie } from '~/types'
10 |
11 | export async function loader({ params }: LoaderArgs) {
12 | const { slug } = params
13 |
14 | if (!slug) {
15 | throw redirect('/movies')
16 | }
17 |
18 | const movieResponse = await fetch(
19 | `https://api.example.com/movies/${slug}`
20 | ).catch((error) => {
21 | throw redirect('/movies')
22 | })
23 | const movie = (await movieResponse.json()) as Movie
24 |
25 | return {
26 | movie,
27 | }
28 | }
29 |
30 | export function meta({ data }: MetaArgs) {
31 | const { movie } = data || {}
32 |
33 | if (movie == null) {
34 | throw new Response('Missing movie', { status: 400 })
35 | }
36 |
37 | return [
38 | {
39 | title: `${movie.title} - Movie App`,
40 | },
41 | ]
42 | }
43 |
44 | export default function MoviePlayer() {
45 | const { movie } = useLoaderData()
46 | const videoRef = useRef(null)
47 | const controls = useVideoControls(videoRef)
48 | const navigate = useNavigate()
49 |
50 | const mediaSourceUrl = useMediaSource(movie.slug)
51 |
52 | useEffect(() => {
53 | const goBackOnEscape = (event: KeyboardEvent) => {
54 | if (event.key === 'Escape') {
55 | navigate(`/movies/${movie.slug}`)
56 | }
57 | }
58 |
59 | document.addEventListener('keydown', goBackOnEscape)
60 |
61 | return () => {
62 | document.removeEventListener('keydown', goBackOnEscape)
63 | }
64 | })
65 |
66 | return (
67 |
68 |
69 |
70 |
{movie.title}
71 |
{movie.description}
72 |
73 |
74 |
78 |
79 |
80 |
81 |
82 |
83 |
90 |
91 |
133 |
134 | )
135 | }
136 |
137 | function useMediaSource(slug: string): string {
138 | const [url, setUrl] = useState('')
139 |
140 | useEffect(() => {
141 | if (typeof MediaSource === 'undefined') {
142 | return
143 | }
144 |
145 | const mediaSource = new MediaSource()
146 | const mediaSourceUrl = URL.createObjectURL(mediaSource)
147 | setUrl(mediaSourceUrl)
148 |
149 | const fetchRemoteStream = async () => {
150 | const buffer = mediaSource.addSourceBuffer(
151 | 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
152 | )
153 | const response = await fetch(
154 | `https://api.example.com/movies/${slug}/stream`
155 | )
156 |
157 | if (!response.body) {
158 | return
159 | }
160 |
161 | if (!response.ok) {
162 | mediaSource.endOfStream('network')
163 | return
164 | }
165 |
166 | const reader = response.body.getReader()
167 |
168 | async function readChunk(): Promise {
169 | const { value, done } = await reader.read()
170 |
171 | if (done) {
172 | return
173 | }
174 |
175 | await new Promise((resolve, reject) => {
176 | buffer.addEventListener('updateend', resolve, { once: true })
177 | buffer.addEventListener('error', reject, { once: true })
178 | buffer.appendBuffer(value)
179 | })
180 |
181 | return readChunk()
182 | }
183 |
184 | await readChunk()
185 | }
186 |
187 | mediaSource.addEventListener('sourceopen', fetchRemoteStream)
188 |
189 | return () => {
190 | mediaSource.removeEventListener('sourceopen', fetchRemoteStream)
191 | if (mediaSource.readyState === 'open') {
192 | mediaSource.endOfStream()
193 | }
194 | }
195 | }, [slug])
196 |
197 | return url
198 | }
199 |
200 | function useVideoControls(videoRef: React.RefObject) {
201 | const [isPlaying, setIsPlaying] = useState(false)
202 | const [timeElapsed, setTimeElapsed] = useState(0)
203 | const [volume, setVolume] = useState(1)
204 |
205 | useEffect(() => {
206 | const { current: player } = videoRef
207 |
208 | if (!player) {
209 | return
210 | }
211 |
212 | player.addEventListener('play', () => setIsPlaying(true))
213 | player.addEventListener('pause', () => setIsPlaying(false))
214 |
215 | player.addEventListener('timeupdate', () => {
216 | const timeElapsedPercent = Math.floor(
217 | (100 / player.duration || 0) * player.currentTime
218 | )
219 |
220 | setTimeElapsed(timeElapsedPercent)
221 | })
222 |
223 | player.addEventListener('volumechange', () => {
224 | setVolume(player.volume)
225 | })
226 | }, [videoRef])
227 |
228 | const play = () => {
229 | videoRef.current?.play()
230 | }
231 |
232 | const pause = () => {
233 | videoRef.current?.pause()
234 | }
235 |
236 | const togglePlay = () => {
237 | if (isPlaying) {
238 | pause()
239 | } else {
240 | play()
241 | }
242 | }
243 |
244 | const updateTime = (timeElapsedPercent: number) => {
245 | const player = videoRef.current
246 |
247 | if (!player) {
248 | return
249 | }
250 |
251 | const nextTime = (player.duration / 100) * timeElapsedPercent
252 | player.currentTime = nextTime
253 | }
254 |
255 | const updateVolume = (nextVolume: number) => {
256 | const player = videoRef.current
257 |
258 | if (!player) {
259 | return
260 | }
261 |
262 | player.volume = nextVolume
263 | }
264 |
265 | return {
266 | isPlaying,
267 | timeElapsed,
268 | volume,
269 | play,
270 | pause,
271 | togglePlay,
272 | updateTime,
273 | updateVolume,
274 | }
275 | }
276 |
--------------------------------------------------------------------------------