Squashed commit of the following:
commit b908db03ad9a982e0cf4e9f964ad03ba06a17294
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:02:56 2025 +0200
Add SearchPage and enhance Navbar with Search component: create a new SearchPage for displaying search results, update Navbar to include a Search component with improved UI and functionality for movie searching.
commit ac1e2e67ec9a68d022123a7f0e3fa4a6e3fbe5ea
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:02:50 2025 +0200
Enhance MovieCard and Search components: implement a new layout for MovieCard, add interactive buttons for managing movie state, and improve Search functionality with pagination support and a refined input design.
commit ce604baf702a418252c3575a0d575221a5492372
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:02:27 2025 +0200
Add Button and Pagination components: create reusable Button component with support for links and a Pagination component for navigating between pages, enhancing UI functionality and user experience.
commit b60a43e7e15a92f02ac6fb7eb8d5304885453e5f
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:02:13 2025 +0200
Add logo SVG and update global styles: introduce a new logo.svg file, enhance color palette in globals.css with additional shades for primary, secondary, and accent colors, and adjust layout components to incorporate Navbar in global data layout.
commit b2bfc87bd391df65277ad78a617800f51bc8f069
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:01:15 2025 +0200
Add custom hooks: implement useKeyListener for keyboard event handling, useLocalStorage for managing local storage state, and useOutsideClick for detecting clicks outside a specified element.
commit fdf2a72eb1f6d93163476b04c735cc4a05bf6208
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:01:08 2025 +0200
Refactor TMDB API integration: update fetch function to accept path parameters, enhance search functionality with additional options, and include total results in SearchResult type for improved data handling.
commit 62ab1698b6372940f12ff01f819cbdf662b7ad8a
Author: Norbert Maciaszek <norbert@ambiscale.com>
Date: Sat Aug 9 20:00:57 2025 +0200
Update package dependencies: add dotenv, drizzle-orm, and react-icons; update drizzle-kit and tsx versions; enhance package-lock.json with new modules and dependencies for improved functionality and compatibility.
This commit is contained in:
parent
8b1bd6e174
commit
5c3423c353
File diff suppressed because it is too large
Load Diff
|
|
@ -14,7 +14,8 @@
|
|||
"drizzle-orm": "^0.44.4",
|
||||
"next": "15.4.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#fff" fill-opacity=".01" d="M0 0h48v48H0z" />
|
||||
<path fill="#0079b4" stroke="#000" stroke-linejoin="round" stroke-width="4"
|
||||
d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4 4 12.954 4 24s8.954 20 20 20Z" />
|
||||
<path fill="#85171f" stroke="#fff" stroke-linejoin="round" stroke-width="4"
|
||||
d="M24 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM24 36a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM15 27a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM33 27a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
<path stroke="#000" stroke-linecap="round" stroke-width="4" d="M24 44h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
|
|
@ -1,5 +1,6 @@
|
|||
import { getMovies } from "@/lib/db";
|
||||
import { GlobalStoreProvider } from "../store/globalStore";
|
||||
import { Navbar } from "@/components/organisms/Navbar";
|
||||
|
||||
export const revalidate = 0;
|
||||
export default async function WithGlobalDataLayout({
|
||||
|
|
@ -10,6 +11,11 @@ export default async function WithGlobalDataLayout({
|
|||
const movies = await getMovies();
|
||||
|
||||
return (
|
||||
<GlobalStoreProvider initialMovies={movies}>{children}</GlobalStoreProvider>
|
||||
<GlobalStoreProvider initialMovies={movies}>
|
||||
<Navbar />
|
||||
<main className="py-10 [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</main>
|
||||
</GlobalStoreProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { MovieList } from "@/components/molecules/MovieList";
|
||||
import { SearchMovies } from "@/components/molecules/SearchMovies";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<SearchMovies />
|
||||
<>
|
||||
<MovieList
|
||||
heading="Upcoming"
|
||||
filterUpcoming={1}
|
||||
|
|
@ -19,6 +17,6 @@ export default async function Home() {
|
|||
/>
|
||||
<MovieList heading="Seen" filterSeen={1} />
|
||||
<MovieList heading="Favorites" filterFavorites={1} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { SearchList } from "@/components/molecules/SearchMovies";
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ s: string }>;
|
||||
}) {
|
||||
const { s } = await searchParams;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="container">
|
||||
<h1 className="text-2xl">
|
||||
Search for: <strong>{s}</strong>
|
||||
</h1>
|
||||
</section>
|
||||
<SearchList query={s} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,19 +3,72 @@
|
|||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: #0e1428;
|
||||
--color-primary: #c73b6f;
|
||||
--color-accent: #7b9e89;
|
||||
--color-text: #eaeaea;
|
||||
--color-textSecondary: #aaaaaa;
|
||||
--color-statusSeen: #4ade80;
|
||||
--color-statusUpcoming: #facc15;
|
||||
--color-statusArchived: #525252;
|
||||
--color-text: #1d1d1d;
|
||||
|
||||
--color-primary-50: #eefaff;
|
||||
--color-primary-100: #dcf5ff;
|
||||
--color-primary-200: #b2edff;
|
||||
--color-primary-300: #6de1ff;
|
||||
--color-primary-400: #20d3ff;
|
||||
--color-primary-500: #00beff;
|
||||
--color-primary-600: #0099df;
|
||||
--color-primary-700: #0079b4;
|
||||
--color-primary: #006795;
|
||||
--color-primary-900: #00547a;
|
||||
--color-primary-950: #003049;
|
||||
|
||||
--color-secondary-50: #ffeeee;
|
||||
--color-secondary-100: #ffdada;
|
||||
--color-secondary-200: #ffbbbb;
|
||||
--color-secondary-300: #ff8b8b;
|
||||
--color-secondary-400: #ff4949;
|
||||
--color-secondary-500: #ff1111;
|
||||
--color-secondary-600: #ff0000;
|
||||
--color-secondary-700: #e70000;
|
||||
--color-secondary: #be0000;
|
||||
--color-secondary-900: #780000;
|
||||
--color-secondary-950: #560000;
|
||||
|
||||
--color-monza-50: #fff1f2;
|
||||
--color-monza-100: #ffe0e2;
|
||||
--color-monza-200: #ffc7cb;
|
||||
--color-monza-300: #ffa0a7;
|
||||
--color-monza-400: #ff6974;
|
||||
--color-monza-500: #fa3947;
|
||||
--color-monza-600: #e71b2a;
|
||||
--color-monza-700: #c1121f;
|
||||
--color-monza-800: #a1131e;
|
||||
--color-monza-900: #85171f;
|
||||
--color-monza-950: #49060b;
|
||||
|
||||
--color-pink-lady-50: #fef9ee;
|
||||
--color-pink-lady-100: #fdf0d5;
|
||||
--color-pink-lady-200: #fadfae;
|
||||
--color-pink-lady-300: #f7c77a;
|
||||
--color-pink-lady-400: #f2a545;
|
||||
--color-pink-lady-500: #ef8a20;
|
||||
--color-pink-lady-600: #e07016;
|
||||
--color-pink-lady-700: #ba5514;
|
||||
--color-pink-lady-800: #944418;
|
||||
--color-pink-lady-900: #773917;
|
||||
--color-pink-lady-950: #401c0a;
|
||||
|
||||
--color-hippie-blue-50: #f4f7fb;
|
||||
--color-hippie-blue-100: #e9eff5;
|
||||
--color-hippie-blue-200: #cddeea;
|
||||
--color-hippie-blue-300: #a2c2d7;
|
||||
--color-hippie-blue-400: #669bbc;
|
||||
--color-hippie-blue-500: #4e86a9;
|
||||
--color-hippie-blue-600: #3b6c8e;
|
||||
--color-hippie-blue-700: #315773;
|
||||
--color-hippie-blue-800: #2c4a60;
|
||||
--color-hippie-blue-900: #283f52;
|
||||
--color-hippie-blue-950: #1b2936;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-background text-text;
|
||||
@apply bg-pink-lady-50 text-text;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
|
@ -37,6 +90,10 @@
|
|||
flex-wrap: wrap;
|
||||
margin: 0 -15px;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
@utility col-* {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import "./globals.css";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "@/components/organisms/Navbar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import Link from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const Button: FC<Props> = ({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
href,
|
||||
}) => {
|
||||
const Component = (href ? Link : "button") as any;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-white transition hover:bg-primary-900 ${className}`}
|
||||
onClick={onClick}
|
||||
{...(href && { href })}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,6 +3,9 @@ import { FC } from "react";
|
|||
import { ReadMore } from "../ReadMore";
|
||||
import { addMovie, deleteMovie, updateMovie } from "@/lib/db";
|
||||
import { useGlobalStore } from "@/app/store/globalStore";
|
||||
import { MdFavorite, MdFavoriteBorder, MdOutlinePostAdd } from "react-icons/md";
|
||||
import { RxEyeClosed, RxEyeOpen } from "react-icons/rx";
|
||||
import { IoMdRemoveCircleOutline } from "react-icons/io";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
|
|
@ -14,9 +17,11 @@ type Props = {
|
|||
seen: boolean;
|
||||
favorite: boolean;
|
||||
notes?: string;
|
||||
layout?: "default" | "zeus";
|
||||
};
|
||||
|
||||
const buttonClass = "px-2 py-6 text-sm w-full transition-colors cursor-pointer";
|
||||
const buttonClass =
|
||||
"p-4 text-sm transition-colors cursor-pointer text-center group/toggle";
|
||||
|
||||
export const MovieCard: FC<Props> = ({
|
||||
id,
|
||||
|
|
@ -28,6 +33,7 @@ export const MovieCard: FC<Props> = ({
|
|||
seen,
|
||||
favorite,
|
||||
notes,
|
||||
layout = "default",
|
||||
}) => {
|
||||
const {
|
||||
movies,
|
||||
|
|
@ -35,9 +41,11 @@ export const MovieCard: FC<Props> = ({
|
|||
deleteMovie: deleteMovieFromStore,
|
||||
updateMovie: updateMovieInStore,
|
||||
} = useGlobalStore();
|
||||
console.log(movies);
|
||||
const alreadyInStore = movies.find((m) => m.id === id);
|
||||
|
||||
const isReleased = new Date(releaseDate) < new Date();
|
||||
const iconSize = 64;
|
||||
|
||||
const handleAdd = async () => {
|
||||
const movie = {
|
||||
|
|
@ -70,6 +78,93 @@ export const MovieCard: FC<Props> = ({
|
|||
updateMovieInStore(id, { favorite: favorite ? 0 : 1 });
|
||||
};
|
||||
|
||||
if (layout === "zeus") {
|
||||
return (
|
||||
<article className="flex flex-col w-full shadow-md rounded-lg overflow-hidden bg-white">
|
||||
<figure className="relative ">
|
||||
<img
|
||||
className="w-full object-cover"
|
||||
style={{ height: "420px" }}
|
||||
src={`http://image.tmdb.org/t/p/w342/${imagePath}`}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-md opacity-0 hover:opacity-100 transition-opacity duration-300 flex items-center justify-center cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!alreadyInStore) {
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!alreadyInStore && <MdOutlinePostAdd size={64} color="white" />}
|
||||
{alreadyInStore && (
|
||||
<div className="flex flex-col">
|
||||
<>
|
||||
{isReleased && (
|
||||
<button
|
||||
className={`${buttonClass} text-white`}
|
||||
onClick={handleSeen}
|
||||
>
|
||||
<span className="group-hover/toggle:hidden">
|
||||
{seen ? (
|
||||
<RxEyeOpen size={iconSize} />
|
||||
) : (
|
||||
<RxEyeClosed size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden group-hover/toggle:block">
|
||||
{seen ? (
|
||||
<RxEyeClosed size={iconSize} />
|
||||
) : (
|
||||
<RxEyeOpen size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`${buttonClass} text-amber-400`}
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<span className="group-hover/toggle:hidden">
|
||||
{favorite ? (
|
||||
<MdFavorite size={iconSize} />
|
||||
) : (
|
||||
<MdFavoriteBorder size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden group-hover/toggle:block">
|
||||
{favorite ? (
|
||||
<MdFavoriteBorder size={iconSize} />
|
||||
) : (
|
||||
<MdFavorite size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`${buttonClass} text-red-500`}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<IoMdRemoveCircleOutline size={iconSize} />
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</figure>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-xl leading-[1.1] font-bold">{title}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">{releaseDate}</p>
|
||||
<div className="text-xs text-gray-400 mt-4">
|
||||
<ReadMore text={overview} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full shadow-md rounded-lg overflow-hidden mx-auto group/card">
|
||||
<div className="overflow-hidden rounded-xl relative movie-item text-white movie-card">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import Link from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export const Pagination: FC<Props> = ({
|
||||
totalPages,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
}) => {
|
||||
return (
|
||||
<ul className="flex justify-center gap-3 text-gray-900 my-10">
|
||||
{currentPage > 1 && (
|
||||
<li>
|
||||
<button
|
||||
className="grid size-8 place-content-center rounded border border-primary transition-colors hover:bg-primary hover:text-white cursor-pointer"
|
||||
aria-label="Previous page"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<li className="text-sm/8 font-medium tracking-widest">
|
||||
{currentPage}/{totalPages}
|
||||
</li>
|
||||
|
||||
{currentPage < totalPages && (
|
||||
<li>
|
||||
<button
|
||||
className="grid size-8 place-content-center rounded border border-primary transition-colors hover:bg-primary hover:text-white cursor-pointer"
|
||||
aria-label="Next page"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,7 +12,7 @@ export const ReadMore: FC<Props> = ({ text }) => {
|
|||
<p
|
||||
className={`${
|
||||
isOpen ? "line-clamp-none" : "line-clamp-2"
|
||||
} hover:text-accent`}
|
||||
} hover:text-primary cursor-pointer`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{text}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
"use client";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { IoSearch } from "react-icons/io5";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
debounce?: number;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export const SearchInput: FC<Props> = ({
|
||||
className = "",
|
||||
defaultValue = "",
|
||||
placeholder = "Search",
|
||||
onChange,
|
||||
debounce = 500,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
|
|
@ -25,12 +30,19 @@ export const SearchInput: FC<Props> = ({
|
|||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={`relative text-gray-600 inline-block ${className}`}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className="w-full p-2 rounded-md border border-gray-600 "
|
||||
type="search"
|
||||
name="serch"
|
||||
className="bg-white h-10 px-5 pr-10 rounded-full text-sm focus:outline-none w-48 focus:w-[400px] transition-all"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<button type="submit" className="absolute right-0 top-0 mt-3 mr-4">
|
||||
<IoSearch />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const MovieList: FC<Props> = ({
|
|||
{sortedMovies.map((movie) => (
|
||||
<MovieCard
|
||||
key={movie.id}
|
||||
layout="zeus"
|
||||
{...movie}
|
||||
imagePath={movie.posterPath || ""}
|
||||
seen={movie.seen === 1}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,53 @@
|
|||
"use client";
|
||||
import { SearchInput } from "@/components/atoms/SearchInput";
|
||||
import { useState } from "react";
|
||||
import { TMDB } from "@/lib/tmdb";
|
||||
import { MovieCard } from "@/components/atoms/MovieCard";
|
||||
import { SearchResult } from "@/lib/tmdb/types";
|
||||
import { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Pagination } from "@/components/atoms/Pagination";
|
||||
|
||||
export const SearchMovies = () => {
|
||||
const [results, setResults] = useState<SearchResult["results"]>([]);
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
const data = await TMDB.search(query);
|
||||
setResults(data.results);
|
||||
type Props = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const SearchList: FC<Props> = ({ query }) => {
|
||||
const [response, setResponse] = useState<SearchResult | null>(null);
|
||||
const {
|
||||
results,
|
||||
total_results = 0,
|
||||
total_pages = 0,
|
||||
page = 1,
|
||||
} = response ?? {};
|
||||
|
||||
const handleSearch = async (query: string, page: number) => {
|
||||
const data = await TMDB.search({
|
||||
query,
|
||||
page,
|
||||
});
|
||||
setResponse(data);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
window.history.replaceState({}, "", `?s=${query}&page=${page}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
handleSearch(query, page);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch(query, page);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<section className="mb-4 md:mb-10">
|
||||
<div className="container">
|
||||
<div className="row justify-center">
|
||||
<div className="col-12 md:col-5">
|
||||
<SearchInput onChange={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{total_results} movies found for your search
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mt-8">
|
||||
{results.map((result) => (
|
||||
{results?.map((result) => (
|
||||
<MovieCard
|
||||
layout="zeus"
|
||||
key={result.id}
|
||||
id={result.id}
|
||||
title={result.title}
|
||||
|
|
@ -36,6 +60,11 @@ export const SearchMovies = () => {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
totalPages={total_pages}
|
||||
currentPage={page}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
import { IoClose, IoSearch } from "react-icons/io5";
|
||||
import { useState } from "react";
|
||||
import { SearchResult } from "@/lib/tmdb/types";
|
||||
import { TMDB } from "@/lib/tmdb";
|
||||
import { SearchInput } from "@/components/atoms/SearchInput";
|
||||
import { MovieCard } from "@/components/atoms/MovieCard";
|
||||
import { useKeyListener } from "@/hooks/useKeyListener";
|
||||
import { useRef } from "react";
|
||||
import { useOutsideClick } from "@/hooks/useOutsideClick";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
|
||||
export const Search = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [response, setResponse] = useState<SearchResult | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const { results, total_pages, total_results = 0 } = response ?? {};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
setQuery(query);
|
||||
if (query.length < 3) {
|
||||
setResponse(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await TMDB.search({
|
||||
query,
|
||||
});
|
||||
setResponse(data);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsSearchOpen(false);
|
||||
setResponse(null);
|
||||
};
|
||||
|
||||
useKeyListener("Escape", handleClose);
|
||||
useOutsideClick(ref, handleClose);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="text-text hover:text-primary-900 cursor-pointer"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
>
|
||||
<IoSearch size={25} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
{isSearchOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-20 backdrop-blur-sm overflow-y-auto flex items-center">
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<IoClose size={25} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<div className="container" ref={ref}>
|
||||
<div className="text-center">
|
||||
<SearchInput
|
||||
className="scale-200"
|
||||
onChange={handleSearch}
|
||||
placeholder="Search for a movie"
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mt-24">
|
||||
{results && (
|
||||
<div className="col-span-full">
|
||||
<p className="text-white">{total_results} movies found</p>
|
||||
</div>
|
||||
)}
|
||||
{results?.slice(0, 5).map((result) => (
|
||||
<MovieCard
|
||||
layout="zeus"
|
||||
key={result.id}
|
||||
id={result.id}
|
||||
title={result.title}
|
||||
releaseDate={result.release_date}
|
||||
popularity={result.popularity}
|
||||
overview={result.overview}
|
||||
imagePath={result.poster_path}
|
||||
seen={false}
|
||||
favorite={false}
|
||||
/>
|
||||
))}
|
||||
{total_results > 5 && (
|
||||
<div className="col-span-full text-center">
|
||||
<Button href={`/search?s=${query}`} onClick={handleClose}>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,74 @@
|
|||
import Link from "next/link";
|
||||
import { Search } from "./components/Search";
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: "My wall",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Discover",
|
||||
href: "/discover",
|
||||
},
|
||||
];
|
||||
|
||||
export const Navbar = () => {
|
||||
return (
|
||||
<header className="py-4">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="container">
|
||||
<img
|
||||
className="mx-auto"
|
||||
src="/logo.png"
|
||||
alt="Movie Box"
|
||||
style={{ maxWidth: 200 }}
|
||||
<div className="flex items-center gap-8 py-4">
|
||||
<Link className="block text-teal-600" href="/">
|
||||
<span className="sr-only">Home</span>
|
||||
<img src="/logo.svg" alt="Logo" width={40} />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end md:justify-between">
|
||||
<nav aria-label="Global" className="hidden md:block">
|
||||
<ul className="flex items-center gap-6 text-sm">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
className="text-text/70 font-semibold hover:text-text"
|
||||
href={link.href}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
<Search />
|
||||
<a
|
||||
className="block rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-white transition hover:bg-primary-900"
|
||||
href="#"
|
||||
>
|
||||
Random Movie
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button className="block rounded-sm bg-gray-100 p-2.5 text-gray-600 transition hover:text- md:hidden">
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
export const useKeyListener = (key: string, callback: () => void) => {
|
||||
useEffect(() => {
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === key) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [key]);
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item) setValue(JSON.parse(item));
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}, [value, key]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
export const useOutsideClick = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
callback: () => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
};
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
import { SearchResult } from "./types";
|
||||
|
||||
const fetchTmbd = async (url: string) => {
|
||||
const response = await fetch(url, {
|
||||
const url = "https://api.themoviedb.org/3";
|
||||
const fetchTmbd = async (path: string) => {
|
||||
const response = await fetch(url + path, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TMDB_BEARER}`,
|
||||
},
|
||||
|
|
@ -12,8 +13,26 @@ const fetchTmbd = async (url: string) => {
|
|||
return data;
|
||||
};
|
||||
|
||||
const url = "https://api.themoviedb.org/3";
|
||||
type SearchOptions = {
|
||||
query: string;
|
||||
include_adult?: boolean;
|
||||
language?: string;
|
||||
primary_release_year?: string;
|
||||
page?: number;
|
||||
region?: string;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
export async function search(query: string): Promise<SearchResult> {
|
||||
return await fetchTmbd(`${url}/search/movie?query=${query}`);
|
||||
export async function search(options: SearchOptions): Promise<SearchResult> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
options.language = "pl-PL";
|
||||
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return await fetchTmbd(`/search/movie?${params.toString()}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,6 @@ export type SearchResult = {
|
|||
vote_average: number;
|
||||
vote_count: number;
|
||||
}[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue