├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── app ├── client │ ├── layout.tsx │ ├── swr │ │ └── page.tsx │ └── use-query │ │ ├── layout.tsx │ │ └── page.tsx ├── layout.tsx ├── local │ ├── jotai │ │ └── page.tsx │ ├── layout.tsx │ └── use-state │ │ └── page.tsx ├── page.tsx └── server │ ├── layout.tsx │ ├── search-params │ └── page.tsx │ └── server-actions │ └── page.tsx ├── atoms └── search-atoms.ts ├── components ├── icons.tsx ├── layout.tsx ├── main-nav.tsx ├── movies │ ├── movie-item.tsx │ ├── movies-list-server.tsx │ └── movies-list.tsx ├── page-hero.tsx ├── search │ ├── search-client.tsx │ ├── search-server-actions.tsx │ └── search-server-params.tsx ├── site-header.tsx ├── tailwind-indicator.tsx ├── theme-provider.tsx ├── theme-toggle.tsx └── ui │ ├── button.tsx │ ├── input.tsx │ ├── prosandcons-item.tsx │ └── spinner.tsx ├── config └── site.ts ├── lib ├── fonts.ts └── utils.ts ├── movies-data.ts ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── types └── nav.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn"], 18 | "config": "./tailwind.config.js" 19 | }, 20 | "next": { 21 | "rootDir": ["./"] 22 | } 23 | }, 24 | "overrides": [ 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full tutorial on Youtube: 2 | 3 | [![](https://img.youtube.com/vi/dSMp-oihHnw/0.jpg)](https://www.youtube.com/watch?v=dSMp-oihHnw) 4 | 5 | # next-template 6 | 7 | A Next.js 13 template for building apps with Radix UI and Tailwind CSS. 8 | 9 | ## Usage 10 | 11 | ```bash 12 | npx create-next-app -e https://github.com/shadcn/next-template 13 | ``` 14 | 15 | ## Features 16 | 17 | - Radix UI Primitives 18 | - Tailwind CSS 19 | - Fonts with `next/font` 20 | - Icons from [Lucide](https://lucide.dev) 21 | - Dark mode with `next-themes` 22 | - Automatic import sorting with `@ianvs/prettier-plugin-sort-imports` 23 | - Tailwind CSS class sorting, merging and linting. 24 | 25 | ## License 26 | 27 | Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md). 28 | -------------------------------------------------------------------------------- /app/client/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | const Layout = ({ children }: { children: ReactNode }) => { 4 | return
{children}
5 | } 6 | 7 | export default Layout 8 | -------------------------------------------------------------------------------- /app/client/swr/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { MOVIE_DATA, Movie } from "@/movies-data" 5 | import useSWR from "swr" 6 | 7 | import { siteConfig } from "@/config/site" 8 | import { supabaseClient } from "@/lib/utils" 9 | import MoviesList from "@/components/movies/movies-list" 10 | import PageHero from "@/components/page-hero" 11 | import SearchClient from "@/components/search/search-client" 12 | 13 | const Page = () => { 14 | const [inputValue, setInputValue] = useState("") 15 | const [debouncedValue, setDebouncedValue] = useState("") 16 | const [movies, setMovies] = useState(MOVIE_DATA) 17 | 18 | // Fetcher 19 | const fetcher = async (query: string) => { 20 | const { data: movies, error } = await supabaseClient 21 | .from("movies") 22 | .select("*") 23 | .textSearch("fts", query) 24 | .returns() 25 | if (error) { 26 | throw error 27 | } else { 28 | return movies 29 | } 30 | } 31 | 32 | const { data: moviesData, isValidating } = useSWR( 33 | debouncedValue ?? null, 34 | fetcher 35 | ) 36 | 37 | // EFFECTS: Set Data 38 | useEffect(() => { 39 | // If search is active, set movies to search results 40 | if (debouncedValue.length > 0) { 41 | // If there is a result, set movies to result 42 | if (moviesData) { 43 | setMovies(moviesData) 44 | } 45 | // If there is no result, set movies to empty array 46 | else { 47 | setMovies([]) 48 | } 49 | } 50 | // If search is not active, set movies to initial data 51 | else { 52 | setMovies(MOVIE_DATA) 53 | } 54 | }, [moviesData, debouncedValue]) 55 | 56 | // EFFECT: Debounce Input Value 57 | useEffect(() => { 58 | const timer = setTimeout(() => { 59 | setDebouncedValue(inputValue) 60 | }, 500) 61 | 62 | return () => { 63 | clearTimeout(timer) 64 | } 65 | }, [inputValue]) 66 | 67 | return ( 68 |
69 | {/* Hero */} 70 | link.id === 3)?.prosandcons 76 | } 77 | /> 78 | {/* Search */} 79 | 84 | {/* Producs */} 85 | 86 |
87 | ) 88 | } 89 | 90 | export default Page 91 | -------------------------------------------------------------------------------- /app/client/use-query/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ReactNode } from "react" 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 5 | 6 | // Create a client 7 | const queryClient = new QueryClient() 8 | 9 | const QueryClientLayout = ({ children }: { children: ReactNode }) => { 10 | return ( 11 | {children} 12 | ) 13 | } 14 | 15 | export default QueryClientLayout 16 | -------------------------------------------------------------------------------- /app/client/use-query/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { MOVIE_DATA, Movie } from "@/movies-data" 5 | import { useQuery } from "@tanstack/react-query" 6 | 7 | import { siteConfig } from "@/config/site" 8 | import { supabaseClient } from "@/lib/utils" 9 | import MoviesList from "@/components/movies/movies-list" 10 | import PageHero from "@/components/page-hero" 11 | import SearchClient from "@/components/search/search-client" 12 | 13 | const Page = () => { 14 | const [inputValue, setInputValue] = useState("") 15 | const [debouncedValue, setDebouncedValue] = useState("") 16 | const [movies, setMovies] = useState(MOVIE_DATA) 17 | 18 | // Fetcher 19 | const fetcher = async (query: string) => { 20 | const { data: movies, error } = await supabaseClient 21 | .from("movies") 22 | .select("*") 23 | .textSearch("fts", query) 24 | .returns() 25 | 26 | if (error) { 27 | throw error 28 | } else { 29 | return movies 30 | } 31 | } 32 | 33 | // Queries 34 | const query = useQuery({ 35 | queryKey: [debouncedValue], 36 | queryFn: async (query) => fetcher(query.queryKey[0]), 37 | enabled: debouncedValue.length > 0, 38 | }) 39 | 40 | // EFFECTS: Set Data 41 | useEffect(() => { 42 | // If search is active, set movies to search results 43 | if (debouncedValue.length > 0) { 44 | // If there is a result, set movies to result 45 | if (query.data) { 46 | setMovies(query.data) 47 | } 48 | // If there is no result, set movies to empty array 49 | else { 50 | setMovies([]) 51 | } 52 | } 53 | // If search is not active, set movies to initial data 54 | else { 55 | setMovies(MOVIE_DATA) 56 | } 57 | }, [query.data, debouncedValue]) 58 | 59 | // EFFECT: Debounce Input Value 60 | useEffect(() => { 61 | const timer = setTimeout(() => { 62 | setDebouncedValue(inputValue) 63 | }, 500) 64 | 65 | return () => { 66 | clearTimeout(timer) 67 | } 68 | }, [inputValue]) 69 | 70 | return ( 71 |
72 | {/* Hero */} 73 | link.id === 4)?.prosandcons 79 | } 80 | /> 81 | {/* Search */} 82 | 83 | {/* Producs */} 84 | 85 | 86 |
87 | ) 88 | } 89 | 90 | export default Page 91 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | import { Metadata } from "next" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { fontSans } from "@/lib/fonts" 6 | import { cn } from "@/lib/utils" 7 | import { SiteHeader } from "@/components/site-header" 8 | import { ThemeProvider } from "@/components/theme-provider" 9 | 10 | export const metadata: Metadata = { 11 | title: { 12 | default: siteConfig.name, 13 | template: `%s - ${siteConfig.name}`, 14 | }, 15 | description: siteConfig.description, 16 | themeColor: [ 17 | { media: "(prefers-color-scheme: light)", color: "white" }, 18 | { media: "(prefers-color-scheme: dark)", color: "black" }, 19 | ], 20 | icons: { 21 | icon: "/favicon.ico", 22 | shortcut: "/favicon-16x16.png", 23 | apple: "/apple-touch-icon.png", 24 | }, 25 | } 26 | 27 | interface RootLayoutProps { 28 | children: React.ReactNode 29 | } 30 | 31 | export default function RootLayout({ children }: RootLayoutProps) { 32 | return ( 33 | <> 34 | 35 | 36 | 42 | 43 |
44 | 45 |
{children}
46 |
47 |
48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/local/jotai/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { filteredMoviesAtom, inputAtom } from "@/atoms/search-atoms" 4 | import { useAtom, useAtomValue } from "jotai" 5 | 6 | import { siteConfig } from "@/config/site" 7 | import MoviesList from "@/components/movies/movies-list" 8 | import PageHero from "@/components/page-hero" 9 | import SearchClient from "@/components/search/search-client" 10 | 11 | const Page = () => { 12 | const [inputValue, setInputValue] = useAtom(inputAtom) 13 | const movies = useAtomValue(filteredMoviesAtom) 14 | 15 | return ( 16 |
17 | {/* Hero */} 18 | link.id === 2)?.prosandcons 24 | } 25 | /> 26 | {/* Search */} 27 | 28 | {/* Producs */} 29 | 30 |
31 | ) 32 | } 33 | 34 | export default Page 35 | -------------------------------------------------------------------------------- /app/local/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | const Layout = ({ children }: { children: ReactNode }) => { 4 | return
{children}
5 | } 6 | 7 | export default Layout 8 | -------------------------------------------------------------------------------- /app/local/use-state/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { MOVIE_DATA } from "@/movies-data" 4 | import { useCallback, useEffect, useState } from "react" 5 | 6 | import MoviesList from "@/components/movies/movies-list" 7 | import PageHero from "@/components/page-hero" 8 | import SearchClient from "@/components/search/search-client" 9 | import { siteConfig } from "@/config/site" 10 | 11 | const Page = () => { 12 | const [inputValue, setInputValue] = useState("") 13 | const [initialList] = useState(MOVIE_DATA) 14 | const [filteredList, setFilteredList] = useState(MOVIE_DATA) 15 | 16 | // Search Handler 17 | const searchHandler = useCallback(() => { 18 | const filteredData = initialList.filter((movie) => { 19 | return movie.title.toLowerCase().includes(inputValue.toLowerCase()) 20 | }) 21 | setFilteredList(filteredData) 22 | }, [initialList, inputValue]) 23 | 24 | // EFFECT: Search Handler 25 | useEffect(() => { 26 | // Debounce search handler 27 | const timer = setTimeout(() => { 28 | searchHandler() 29 | }, 500) 30 | 31 | // Cleanup 32 | return () => { 33 | clearTimeout(timer) 34 | } 35 | }, [searchHandler]) 36 | 37 | return ( 38 |
39 | {/* Hero */} 40 | link.id === 1)?.prosandcons} 45 | /> 46 | {/* Search */} 47 | 48 | {/* Producs */} 49 | 0 ? filteredList : initialList} /> 50 |
51 | ) 52 | } 53 | 54 | export default Page 55 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | 5 | const getTagBackground = (type: string) => { 6 | switch (type) { 7 | case "Local": 8 | return "dark:bg-emerald-900/20 bg-emerald-900/10 text-emerald-900" 9 | case "Client": 10 | return "dark:bg-yellow-900/20 bg-yellow-900/10 text-yellow-900" 11 | case "Server": 12 | return "dark:bg-blue-900/20 bg-blue-900/10 text-blue-900" 13 | } 14 | } 15 | 16 | export default function IndexPage() { 17 | return ( 18 |
19 |
20 |

21 | How to create Search Experience with Next.js 13 22 |

23 |

24 | We will focus on 3 main approach to create search experience with 25 | Next.js:{" "} 26 | Local,{" "} 27 | Client and{" "} 28 | Server. 29 |

30 |
31 |
32 | {siteConfig.pages.map((page) => ( 33 | 39 |
44 | {page.category} 45 |
46 |

{page.title}

47 | 48 | ))} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/server/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | const Layout = ({ children }: { children: ReactNode }) => { 4 | return
{children}
5 | } 6 | 7 | export default Layout 8 | -------------------------------------------------------------------------------- /app/server/search-params/page.tsx: -------------------------------------------------------------------------------- 1 | import { Movie } from "@/movies-data" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { supabaseClient } from "@/lib/utils" 5 | import MoviesList from "@/components/movies/movies-list" 6 | import PageHero from "@/components/page-hero" 7 | import SearchServerParams from "@/components/search/search-server-params" 8 | 9 | const Page = async ({ 10 | searchParams, 11 | }: { 12 | searchParams: { search?: string } 13 | }) => { 14 | const searchQuery = searchParams.search ?? "" 15 | 16 | let movies: Movie[] = [] 17 | 18 | // Get Initial Data 19 | const { data: initialMoviesData } = await supabaseClient 20 | .from("movies") 21 | .select("*") 22 | .returns() 23 | 24 | // Search Function 25 | const { data: filteredMoviesData } = await supabaseClient 26 | .from("movies") 27 | .select("*") 28 | .textSearch("fts", searchQuery) 29 | .returns() 30 | 31 | // If there is a search query, set movies to search results 32 | if (searchQuery.length > 0) { 33 | // If there is a result, set movies to result 34 | if (filteredMoviesData) { 35 | movies = filteredMoviesData 36 | } 37 | // If there is no result, set movies to empty array 38 | else { 39 | movies = [] 40 | } 41 | } 42 | // If there is no search query, set movies to initial data 43 | else { 44 | movies = initialMoviesData ?? [] 45 | } 46 | 47 | return ( 48 |
49 | {/* Hero */} 50 | link.id === 5)?.prosandcons 56 | } 57 | /> 58 | {/* Search */} 59 | 60 | {/* Producs */} 61 | 62 |
63 | ) 64 | } 65 | 66 | export default Page 67 | -------------------------------------------------------------------------------- /app/server/server-actions/page.tsx: -------------------------------------------------------------------------------- 1 | import { revalidatePath } from "next/cache" 2 | import { Movie } from "@/movies-data" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { supabaseClient } from "@/lib/utils" 6 | import MoviesList from "@/components/movies/movies-list" 7 | import PageHero from "@/components/page-hero" 8 | import SearchServerActions from "@/components/search/search-server-actions" 9 | 10 | let movies: Movie[] = [] 11 | const Page = async () => { 12 | // Get Initial Data 13 | const { data: initialMoviesData } = await supabaseClient 14 | .from("movies") 15 | .select("*") 16 | .returns() 17 | 18 | // Search Handler 19 | const searchHandler = async (searchQuery: string) => { 20 | "use server" 21 | 22 | if (searchQuery.length > 0) { 23 | const { data: filteredMoviesData } = await supabaseClient 24 | .from("movies") 25 | .select("*") 26 | .textSearch("fts", searchQuery) 27 | .returns() 28 | 29 | movies = filteredMoviesData ?? [] 30 | } else { 31 | movies = initialMoviesData ?? [] 32 | } 33 | 34 | revalidatePath("/server/server-actions") 35 | } 36 | 37 | // Deactivate search 38 | const deactivateSearch = async () => { 39 | "use server" 40 | movies = initialMoviesData ?? [] 41 | revalidatePath("/server/server-actions") 42 | } 43 | 44 | return ( 45 |
46 | {/* Hero */} 47 | link.id === 6)?.prosandcons 53 | } 54 | /> 55 | {/* Search */} 56 | 60 | {/* Movies */} 61 | 62 |
63 | ) 64 | } 65 | 66 | export default Page 67 | -------------------------------------------------------------------------------- /atoms/search-atoms.ts: -------------------------------------------------------------------------------- 1 | import { MOVIE_DATA, Movie } from "@/movies-data" 2 | import { atom } from "jotai" 3 | 4 | export const inputAtom = atom("") 5 | export const moviesServerAtom = atom([]) 6 | const moviesAtom = atom(MOVIE_DATA) 7 | export const filteredMoviesAtom = atom((get) => { 8 | const inputValue = get(inputAtom) 9 | // If there is no input value, return all movies 10 | if (!inputValue) return get(moviesAtom) 11 | 12 | // If there is an input value, filter movies by title 13 | return get(moviesAtom).filter((movie) => 14 | movie.title.toLowerCase().includes(inputValue.toLowerCase()) 15 | ) 16 | }) 17 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LucideProps, 3 | Moon, 4 | SunMedium, 5 | Twitter, 6 | type Icon as LucideIcon, 7 | } from "lucide-react" 8 | 9 | export type Icon = LucideIcon 10 | 11 | export const Icons = { 12 | sun: SunMedium, 13 | moon: Moon, 14 | twitter: Twitter, 15 | logo: (props: LucideProps) => ( 16 | 17 | 21 | 22 | ), 23 | gitHub: (props: LucideProps) => ( 24 | 25 | 29 | 30 | ), 31 | swr: (props: LucideProps) => ( 32 | 40 | 44 | 45 | ), 46 | react: (props: LucideProps) => ( 47 | 55 | 56 | 60 | 64 | 68 | 72 | 73 | 74 | 75 | 83 | 84 | 85 | 86 | ), 87 | jotai: (props: LucideProps) => ( 88 | 96 | 100 | 104 | 108 | 112 | 116 | 117 | ), 118 | nextjs: (props: LucideProps) => ( 119 | 127 | 131 | 135 | 139 | 143 | 149 | 153 | 157 | 161 | 162 | ), 163 | } 164 | -------------------------------------------------------------------------------- /components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteHeader } from "@/components/site-header" 2 | 3 | interface LayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export function Layout({ children }: LayoutProps) { 8 | return ( 9 | <> 10 | 11 |
{children}
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { Icons } from "@/components/icons" 5 | 6 | export function MainNav() { 7 | return ( 8 |
9 | 10 | 11 | {siteConfig.name} 12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /components/movies/movie-item.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import { Movie } from "@/movies-data" 3 | 4 | const MovieItem = ({ movie }: { movie: Movie }) => { 5 | return ( 6 |
7 | {/* Content */} 8 |
9 |
10 |
11 | {movie.rating} 12 |
13 |
14 | {movie.type.toUpperCase()} 15 |
16 |
17 | {movie.genre.toUpperCase()} 18 |
19 |
20 |

21 | {movie.title} 22 |

23 |

{movie.plot}

24 |
25 | {/* Overlay */} 26 |
27 | {/* BG Image */} 28 | {movie.title} 34 |
35 | ) 36 | } 37 | 38 | export default MovieItem 39 | -------------------------------------------------------------------------------- /components/movies/movies-list-server.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { moviesServerAtom } from "@/atoms/search-atoms" 4 | import { Movie } from "@/movies-data" 5 | import { useAtomValue } from "jotai" 6 | 7 | import MovieItem from "./movie-item" 8 | 9 | const MoviesList = ({ intialMovies }: { intialMovies: Movie[] }) => { 10 | const movies = useAtomValue(moviesServerAtom) 11 | return ( 12 |
13 | {movies.length > 0 ? ( 14 | movies?.map((movie) => ) 15 | ) : ( 16 |
No result found for this query.
17 | )} 18 |
19 | ) 20 | } 21 | 22 | export default MoviesList 23 | -------------------------------------------------------------------------------- /components/movies/movies-list.tsx: -------------------------------------------------------------------------------- 1 | import { Movie } from "@/movies-data" 2 | 3 | import MovieItem from "./movie-item" 4 | 5 | const MoviesList = ({ 6 | movies, 7 | isHandling = false, 8 | }: { 9 | movies: Movie[] 10 | isHandling?: boolean 11 | }) => { 12 | return ( 13 |
14 | {!isHandling ? ( 15 | movies.length > 0 ? ( 16 | movies?.map((movie) => ) 17 | ) : ( 18 |
No result found for this query.
19 | ) 20 | ) : ( 21 |
Searching...
22 | )} 23 |
24 | ) 25 | } 26 | 27 | export default MoviesList 28 | -------------------------------------------------------------------------------- /components/page-hero.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { ArrowLeft } from "lucide-react" 3 | 4 | import { ProsAndCons } from "@/types/nav" 5 | 6 | import { buttonVariants } from "./ui/button" 7 | import ProsAndConsItem from "./ui/prosandcons-item" 8 | 9 | const getTagBackground = (type: string) => { 10 | switch (type) { 11 | case "Local": 12 | return "dark:bg-emerald-900/20 bg-emerald-900/10 text-emerald-900" 13 | case "Client": 14 | return "dark:bg-yellow-900/20 bg-yellow-900/10 text-yellow-900" 15 | case "Server": 16 | return "dark:bg-blue-900/20 bg-blue-900/10 text-blue-900" 17 | } 18 | } 19 | 20 | interface Props { 21 | type: "Local" | "Client" | "Server" 22 | title: string 23 | description: string 24 | prosandcons?: ProsAndCons[] | undefined 25 | } 26 | const PageHero = ({ type, title, description, prosandcons }: Props) => { 27 | return ( 28 |
29 |
30 |
31 | {/* Back Arrow */} 32 | 36 | 37 | 38 |
43 | {type} 44 |
45 |
46 |

47 | {title} 48 |

49 |

50 | {description} 51 |

52 |
53 | {prosandcons?.map((item) => ( 54 | 55 | ))} 56 |
57 |
58 |
59 | ) 60 | } 61 | 62 | export default PageHero 63 | -------------------------------------------------------------------------------- /components/search/search-client.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Input } from "../ui/input" 4 | import Spinner from "../ui/spinner" 5 | 6 | interface Props { 7 | inputValue: string 8 | setInputValue: (value: string) => void 9 | isHandling?: boolean 10 | } 11 | 12 | const SearchClient = ({ inputValue, setInputValue, isHandling }: Props) => { 13 | return ( 14 |
15 | { 18 | setInputValue(e.target.value) 19 | }} 20 | placeholder="Search movies" 21 | className="text-base" 22 | /> 23 | {isHandling && ( 24 |
25 | 26 |
27 | )} 28 |
29 | ) 30 | } 31 | 32 | export default SearchClient 33 | -------------------------------------------------------------------------------- /components/search/search-server-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState, useTransition } from "react" 4 | 5 | import { Input } from "../ui/input" 6 | import Spinner from "../ui/spinner" 7 | 8 | const SearchServerActions = ({ 9 | searchHandler, 10 | deactivateSearch, 11 | }: { 12 | searchHandler: (searchQuery: string) => Promise 13 | deactivateSearch: () => Promise 14 | }) => { 15 | const [inputValue, setInputValue] = useState("") 16 | 17 | const [isPending, startTransition] = useTransition() 18 | 19 | const submitHandler = () => { 20 | startTransition(() => { 21 | searchHandler(inputValue) 22 | }) 23 | } 24 | 25 | useEffect(() => { 26 | if (inputValue.length === 0) { 27 | startTransition(() => { 28 | deactivateSearch() 29 | }) 30 | } 31 | }, [inputValue]) 32 | 33 | return ( 34 |
35 | { 38 | setInputValue(e.target.value) 39 | }} 40 | placeholder="Search movies" 41 | className="text-base" 42 | /> 43 | {isPending && ( 44 |
45 | 46 |
47 | )} 48 |
49 | ) 50 | } 51 | 52 | export default SearchServerActions 53 | -------------------------------------------------------------------------------- /components/search/search-server-params.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useCallback, useEffect, useState, useTransition } from "react" 4 | import { usePathname, useRouter } from "next/navigation" 5 | 6 | import { Input } from "../ui/input" 7 | import Spinner from "../ui/spinner" 8 | 9 | const SearchServerParams = () => { 10 | const [inputValue, setInputValue] = useState("") 11 | const [debouncedValue, setDebouncedValue] = useState("") 12 | const [mounted, setMounted] = useState(false) 13 | const router = useRouter() 14 | const pathname = usePathname() 15 | const [isPending, startTransition] = useTransition() 16 | 17 | const handleSearchParams = useCallback( 18 | (debouncedValue: string) => { 19 | let params = new URLSearchParams(window.location.search) 20 | if (debouncedValue.length > 0) { 21 | params.set("search", debouncedValue) 22 | } else { 23 | params.delete("search") 24 | } 25 | startTransition(() => { 26 | router.replace(`${pathname}?${params.toString()}`) 27 | }) 28 | }, 29 | [pathname, router] 30 | ) 31 | 32 | // EFFECT: Set Initial Params 33 | useEffect(() => { 34 | const params = new URLSearchParams(window.location.search) 35 | const searchQuery = params.get("search") ?? "" 36 | setInputValue(searchQuery) 37 | }, []) 38 | 39 | // EFFECT: Set Mounted 40 | useEffect(() => { 41 | if (debouncedValue.length > 0 && !mounted) { 42 | setMounted(true) 43 | } 44 | }, [debouncedValue, mounted]) 45 | 46 | // EFFECT: Debounce Input Value 47 | useEffect(() => { 48 | const timer = setTimeout(() => { 49 | setDebouncedValue(inputValue) 50 | }, 500) 51 | 52 | return () => { 53 | clearTimeout(timer) 54 | } 55 | }, [inputValue]) 56 | 57 | // EFFECT: Search Params 58 | useEffect(() => { 59 | if (mounted) handleSearchParams(debouncedValue) 60 | }, [debouncedValue, handleSearchParams, mounted]) 61 | 62 | return ( 63 |
64 | { 67 | setInputValue(e.target.value) 68 | }} 69 | placeholder="Search movies" 70 | className="text-base" 71 | /> 72 | {isPending && ( 73 |
74 | 75 |
76 | )} 77 |
78 | ) 79 | } 80 | 81 | export default SearchServerParams 82 | -------------------------------------------------------------------------------- /components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { ThemeToggle } from "@/components/theme-toggle" 8 | 9 | export function SiteHeader() { 10 | return ( 11 |
12 |
13 | 14 |
15 | 48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null 3 | 4 | return ( 5 |
6 |
xs
7 |
8 | sm 9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | export function ThemeToggle() { 10 | const { setTheme, theme } = useTheme() 11 | 12 | return ( 13 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 |