├── .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 | {alt} 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 | 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 | {title} 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 |
18 |
19 | 20 | 25 |
26 |
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 | 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 | 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 |