Compare commits
No commits in common. "interview_start" and "main" have entirely different histories.
interview_
...
main
64
Pagination.tsx
Normal file
64
Pagination.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { ReactEventHandler, useState } from "react";
|
||||
export default function Pagination({ pages, onPageChange, currentPage }: any) {
|
||||
const [page, setPage] = useState(currentPage);
|
||||
const handleClick = (e: any) => {
|
||||
const { value } = e.target;
|
||||
let newPage;
|
||||
|
||||
if (isNaN(parseInt(value))) {
|
||||
switch (value) {
|
||||
case "<<":
|
||||
// goto start
|
||||
newPage = 0;
|
||||
break;
|
||||
case "<":
|
||||
newPage = page - 1;
|
||||
break;
|
||||
case ">":
|
||||
newPage = page + 1;
|
||||
break;
|
||||
case ">>":
|
||||
//goto end
|
||||
newPage = pages.length - 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
newPage = value;
|
||||
}
|
||||
|
||||
setPage(newPage);
|
||||
onPageChange(newPage);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// onPageChange(page);
|
||||
// }, [page]);
|
||||
|
||||
const QuickLink = ({ value }: { value: string }) => {
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
style={{ fontWeight: value == page ? "bold" : "normal" }}
|
||||
// value={value}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickLink value={"<<"} />
|
||||
<QuickLink value={"<"} />
|
||||
{pages.map((p: number) => (
|
||||
<QuickLink value={p.toString()} />
|
||||
))}
|
||||
<QuickLink value={">"} />
|
||||
<QuickLink value={">>"} />
|
||||
</>
|
||||
);
|
||||
}
|
23
movie-search/__tests__/MovieCard.test.jsx
Normal file
23
movie-search/__tests__/MovieCard.test.jsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import MovieCard from "../src/common/components/MovieCard.tsx";
|
||||
const movie = {
|
||||
Title: "Dune",
|
||||
Year: "2000",
|
||||
imdbID: "tt0142032",
|
||||
Type: "series",
|
||||
Poster:
|
||||
"https://m.media-amazon.com/images/M/MV5BMTU4MjMyMTkxN15BMl5BanBnXkFtZTYwODA5OTU5._V1_SX300.jpg",
|
||||
};
|
||||
describe("MovieCard Rendering", () => {
|
||||
test("should render with Dune title.", () => {
|
||||
render(<MovieCard movie={movie} />);
|
||||
const movieTitle = screen.getByRole("heading");
|
||||
expect(movieTitle).toHaveTextContent("Dune");
|
||||
});
|
||||
test("should render with Year 2000.", () => {
|
||||
render(<MovieCard movie={movie} />);
|
||||
const movieTitle = screen.getByTestId("MovieCard-Year");
|
||||
expect(movieTitle).toHaveTextContent("2000");
|
||||
});
|
||||
});
|
41
movie-search/__tests__/SearchBar.test.jsx
Normal file
41
movie-search/__tests__/SearchBar.test.jsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import SearchBar from "@/app/Search/SearchBar.tsx";
|
||||
import { queryOMDb } from "@/api/omdb";
|
||||
|
||||
describe("SearchBar Operations", () => {
|
||||
test("SearchBar Allows user input", () => {
|
||||
render(<SearchBar />);
|
||||
const input = screen.getByPlaceholderText("Search by Title");
|
||||
|
||||
fireEvent.change(input, { target: { value: "some value" } });
|
||||
|
||||
expect(input.value).toBe("some value");
|
||||
});
|
||||
|
||||
test("SearchBar Allows user input and Clear button clears", () => {
|
||||
render(<SearchBar />);
|
||||
const input = screen.getByTestId("SearchBar-input");
|
||||
const clear = screen.getByText("Clear");
|
||||
|
||||
fireEvent.change(input, { target: { value: "some value" } });
|
||||
expect(input.value).toBe("some value");
|
||||
|
||||
fireEvent.click(clear);
|
||||
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
// test("SearchBar Allows user submit", () => {
|
||||
// render(<SearchBar />);
|
||||
// const input = screen.getByPlaceholderText("Search by Title");
|
||||
// const submit = screen.getByText("Search");
|
||||
// const spy = jest.spyOn({ queryOMDb }, "queryOMDb");
|
||||
|
||||
// fireEvent.change(input, { target: { value: "back to the" } });
|
||||
// fireEvent.click(submit);
|
||||
// // Somehow test for form submission - currently facing this issue
|
||||
// // https://github.com/vercel/next.js/issues/54757
|
||||
// // expect(spy).toHaveBeenCalled();
|
||||
// });
|
||||
});
|
16
movie-search/__tests__/omdb.test.jsx
Normal file
16
movie-search/__tests__/omdb.test.jsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import "@testing-library/jest-dom";
|
||||
import { queryOMDb } from "../src/api/omdb.ts";
|
||||
|
||||
describe("OMDb API Integration", () => {
|
||||
test("queryOMDb returns movie data successfully", async () => {
|
||||
const data = await queryOMDb("s=back to");
|
||||
|
||||
expect(data).toHaveProperty("Response", "True");
|
||||
});
|
||||
|
||||
test("queryOMDb returns movie with error", async () => {
|
||||
const data = await queryOMDb("f=dune");
|
||||
|
||||
expect(data).toHaveProperty("Response", "False");
|
||||
});
|
||||
});
|
19
movie-search/src/api/omdb.ts
Normal file
19
movie-search/src/api/omdb.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
const url = `${process.env.OMDB_URL}?apikey=${process.env.API_KEY}`;
|
||||
|
||||
export const queryOMDb = async (queryParamString: string) => {
|
||||
"use server";
|
||||
try {
|
||||
// await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const queryUrl = `${url}&${queryParamString}`;
|
||||
const response = await fetch(queryUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch movie data");
|
||||
}
|
||||
const result = await response.json();
|
||||
// console.log(queryUrl, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new Error("Something went wrong searching OMDb");
|
||||
}
|
||||
};
|
12
movie-search/src/app/Favorites/favoriteList.tsx
Normal file
12
movie-search/src/app/Favorites/favoriteList.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { FavoriteContext } from "@/common/components/FavoriteContext";
|
||||
import MovieList from "@/common/components/MovieList";
|
||||
import { useContext } from "react";
|
||||
|
||||
export default function FavoriteList() {
|
||||
const { favorites } = useContext(FavoriteContext);
|
||||
return (
|
||||
<MovieList movies={favorites} />
|
||||
);
|
||||
}
|
10
movie-search/src/app/Favorites/page.tsx
Normal file
10
movie-search/src/app/Favorites/page.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import FavoriteList from "./favoriteList";
|
||||
|
||||
export default function Favorites() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Your Favorites!</h1>
|
||||
<FavoriteList/>
|
||||
</div>
|
||||
);
|
||||
};
|
5
movie-search/src/app/Movie/loading.tsx
Normal file
5
movie-search/src/app/Movie/loading.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Loading from "@/common/components/Loading";
|
||||
|
||||
export default function loading() {
|
||||
return <Loading />;
|
||||
}
|
39
movie-search/src/app/Movie/page.tsx
Normal file
39
movie-search/src/app/Movie/page.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { queryOMDb } from "@/api/omdb";
|
||||
import { INextJsProps } from "../Search/page";
|
||||
import ImgCard from "@/common/components/ImgCard";
|
||||
import { stringifyQueryParams } from "@/common/utils/searchParams";
|
||||
|
||||
export default async function Movie({ searchParams }: INextJsProps) {
|
||||
const movie = await queryOMDb(stringifyQueryParams(searchParams!));
|
||||
const { Poster, Title, Ratings, ...details } = movie;
|
||||
return (
|
||||
<div className="rounded p-4 bg-gray-300 w-auto mx-4 flex-column h-xl">
|
||||
<ImgCard
|
||||
width={400}
|
||||
height={580}
|
||||
src={Poster !== "N/A" ? Poster : ""}
|
||||
alt={`${details.Title} Movie Poster`}
|
||||
>
|
||||
<div className="mt-auto">
|
||||
<h3 className="mb-2 text-lg font-semibold">{Title}</h3>
|
||||
{Object.entries(details).map(([key, value]) => {
|
||||
return (
|
||||
<p key={key} className="mb-2 text-sm text-gray-700">
|
||||
<strong>{key}: </strong>
|
||||
{value as string}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<h3>Ratings:</h3>
|
||||
<ul>
|
||||
{Ratings.map((r: { Source: string; Value: string }) => (
|
||||
<li key={r.Source}>
|
||||
{r.Source} - {r.Value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</ImgCard>
|
||||
</div>
|
||||
);
|
||||
}
|
33
movie-search/src/app/Search/SearchBar.tsx
Normal file
33
movie-search/src/app/Search/SearchBar.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { primaryBtn, secondaryBtn } from "@/common/classes";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SearchBar() {
|
||||
const searchMovies = async (formData: FormData) => {
|
||||
"use server";
|
||||
|
||||
const movieTitle = formData.get("movieTitle");
|
||||
// revalidatePath('/Search')
|
||||
redirect(`/Search?s=${movieTitle}`);
|
||||
};
|
||||
return (
|
||||
<form action={searchMovies} className={`md:flex gap-4 m-4 justify-center`}>
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
data-testid="SearchBar-input"
|
||||
className="rounded px-2 mb-2 md:mb-0"
|
||||
placeholder="Search by Title"
|
||||
name="movieTitle"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button type="submit" className={primaryBtn}>
|
||||
Search
|
||||
</button>
|
||||
<button type="reset" className={secondaryBtn}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
16
movie-search/src/app/Search/SearchPage.tsx
Normal file
16
movie-search/src/app/Search/SearchPage.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { queryOMDb } from "@/api/omdb";
|
||||
import MovieList from "../../common/components/MovieList";
|
||||
|
||||
export default async function SearchPage({
|
||||
queryParamString,
|
||||
}: {
|
||||
queryParamString: string;
|
||||
}) {
|
||||
|
||||
const { Search } = await queryOMDb(queryParamString);
|
||||
const movies = Search || [];
|
||||
|
||||
return (
|
||||
<MovieList movies={movies} />
|
||||
);
|
||||
}
|
22
movie-search/src/app/Search/page.tsx
Normal file
22
movie-search/src/app/Search/page.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { stringifyQueryParams } from "@/common/utils/searchParams";
|
||||
import { Suspense } from "react";
|
||||
import SearchPage from "./SearchPage";
|
||||
import SearchBar from "./SearchBar";
|
||||
import Loading from "@/common/components/Loading";
|
||||
|
||||
export interface INextJsProps {
|
||||
searchParams?: object;
|
||||
}
|
||||
|
||||
export default async function Search({ searchParams }: INextJsProps) {
|
||||
const queryParamString = stringifyQueryParams(searchParams!);
|
||||
|
||||
return (
|
||||
<div className={`mx-auto px-2`}>
|
||||
<SearchBar />
|
||||
<Suspense key={queryParamString} fallback={<Loading />}>
|
||||
<SearchPage queryParamString={queryParamString} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import NavBar from "@/common/components/NavBar";
|
||||
import { FavoriteProvider } from "@/common/components/FavoriteContext";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
|
@ -17,7 +19,10 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className="h-full">
|
||||
<body className={`${inter.className} mb-4 bg-black h-full`}>
|
||||
<NavBar />
|
||||
<FavoriteProvider>
|
||||
{children}
|
||||
</FavoriteProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
34
movie-search/src/common/components/FavoriteBtn.tsx
Normal file
34
movie-search/src/common/components/FavoriteBtn.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { FavoriteContext } from "./FavoriteContext";
|
||||
|
||||
export default function FavoriteBtn({ movie }: any) {
|
||||
const { favorites,
|
||||
dispatch
|
||||
// setFavorites
|
||||
} = useContext(FavoriteContext);
|
||||
const filteredFavorites = favorites.filter((f) => f.imdbID != movie.imdbID);
|
||||
const isFavorite =
|
||||
favorites.filter((f) => f.imdbID == movie.imdbID).length == 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isFavorite) {
|
||||
dispatch({type:'Remove', payload: movie})
|
||||
// setFavorites([...filteredFavorites]);
|
||||
} else {
|
||||
dispatch({type:'Add', payload: movie})
|
||||
// setFavorites([...favorites, movie]);
|
||||
}
|
||||
}}
|
||||
className={`block text-yellow-500 rounded my-auto h-8 w-8
|
||||
${
|
||||
isFavorite ? " bg-black" : "border-2 border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
}
|
59
movie-search/src/common/components/FavoriteContext.tsx
Normal file
59
movie-search/src/common/components/FavoriteContext.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { PropsWithChildren, createContext, useReducer
|
||||
// , useState
|
||||
} from "react";
|
||||
|
||||
// interface IFavoriteContext {
|
||||
// favorites: Array<IMovieSearch>
|
||||
// setFavorites: (value: Array<IMovieSearch>) => void
|
||||
// }
|
||||
// export const FavoriteContext = createContext<IFavoriteContext>({
|
||||
// favorites: [],
|
||||
// setFavorites: (_value: Array<IMovieSearch>) => { },
|
||||
// });
|
||||
|
||||
// // Using interleaving to render this context provider client-side, but still utilize server-side renderung for children components
|
||||
// export const FavoriteProvider = ({ children }: PropsWithChildren) => {
|
||||
// const [favorites, setFavorites] = useState<Array<IMovieSearch>>([])
|
||||
// return (
|
||||
// <FavoriteContext.Provider value={{ favorites, setFavorites }}>{children}</FavoriteContext.Provider>
|
||||
// )
|
||||
// }
|
||||
|
||||
interface IFavoriteContext {
|
||||
favorites: FavoriteState;
|
||||
dispatch: React.Dispatch<FavoriteAction>;
|
||||
}
|
||||
|
||||
type FavoriteState = Array<IMovieSearch>;
|
||||
type FavoriteAction = { type: "Add" | "Remove"; payload: IMovieSearch };
|
||||
|
||||
function FavoriteReducer(state: FavoriteState, action: FavoriteAction) {
|
||||
switch (action.type) {
|
||||
case "Add":
|
||||
state = [...state, action.payload];
|
||||
break;
|
||||
case "Remove":
|
||||
state = state.filter((s) => s.imdbID != action.payload.imdbID);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
const initialState: FavoriteState = [];
|
||||
|
||||
export const FavoriteContext = createContext<IFavoriteContext>({
|
||||
favorites: initialState,
|
||||
dispatch: () => null,
|
||||
});
|
||||
|
||||
export function FavoriteProvider({ children }: PropsWithChildren) {
|
||||
const [favorites, dispatch] = useReducer(FavoriteReducer, initialState);
|
||||
return (
|
||||
<FavoriteContext.Provider value={{ favorites, dispatch }}>
|
||||
{children}
|
||||
</FavoriteContext.Provider>
|
||||
);
|
||||
}
|
41
movie-search/src/common/components/ImgCard.tsx
Normal file
41
movie-search/src/common/components/ImgCard.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import Image from "next/image";
|
||||
import { PropsWithChildren } from "react";
|
||||
interface ICardProps extends PropsWithChildren {
|
||||
width: number;
|
||||
height: number;
|
||||
src: string;
|
||||
alt: string;
|
||||
classNames?: string;
|
||||
}
|
||||
export default function ImgCard({
|
||||
classNames,
|
||||
width,
|
||||
height,
|
||||
src,
|
||||
alt,
|
||||
children,
|
||||
}: ICardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
shadow-lg
|
||||
shadow-purple-500
|
||||
rounded
|
||||
|
||||
p-2
|
||||
bg-white
|
||||
text-black
|
||||
${classNames}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
width={width}
|
||||
height={height}
|
||||
className={`mx-auto h-64 w-48 rounded`}
|
||||
src={src || ""}
|
||||
alt={alt || ""}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
9
movie-search/src/common/components/Loading.tsx
Normal file
9
movie-search/src/common/components/Loading.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="h-svh flex flex-col justify-center">
|
||||
<h1 className="my-auto text-center">
|
||||
<strong>Loading...</strong>
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
32
movie-search/src/common/components/MovieCard.tsx
Normal file
32
movie-search/src/common/components/MovieCard.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import FavoriteBtn from "@/common/components/FavoriteBtn";
|
||||
import ImgCard from "@/common/components/ImgCard";
|
||||
import Link from "next/link";
|
||||
import { primaryBtn } from "../classes";
|
||||
|
||||
interface IMovieCardProps {
|
||||
movie: IMovieSearch;
|
||||
}
|
||||
|
||||
export default function MovieCard({ movie }: IMovieCardProps) {
|
||||
return (
|
||||
<ImgCard
|
||||
classNames={`w-full h-full flex flex-col justify-between`}
|
||||
width={140}
|
||||
height={160}
|
||||
src={movie.Poster !== "N/A" ? movie.Poster : ""}
|
||||
alt={`${movie.Title} Movie Poster`}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className=" text-lg font-semibold">{movie.Title}</h3>
|
||||
<p data-testid='MovieCard-Year' className=" text-sm text-gray-700">Circa: {movie.Year}</p>
|
||||
|
||||
<div className="flex gap-3 justify-around">
|
||||
<Link href={`/Movie?i=${movie.imdbID}`}>
|
||||
<button className={primaryBtn}>Movie Details</button>
|
||||
</Link>
|
||||
<FavoriteBtn movie={movie} />
|
||||
</div>
|
||||
</div>
|
||||
</ImgCard>
|
||||
);
|
||||
}
|
23
movie-search/src/common/components/MovieList.tsx
Normal file
23
movie-search/src/common/components/MovieList.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import MovieCard from "./MovieCard";
|
||||
|
||||
export interface IMovieListProps {
|
||||
movies: Array<IMovieSearch>;
|
||||
}
|
||||
|
||||
export default function MovieList({ movies }: IMovieListProps) {
|
||||
return (
|
||||
<div
|
||||
className={`p-1 flex justify-around flex-wrap gap-4 sm:justify-center `}
|
||||
>
|
||||
{movies.length > 0 ? (
|
||||
movies.map((m) => (
|
||||
<div className="WrapItem w-full mx-5 sm:w-64 mx-1" key={m.imdbID}>
|
||||
<MovieCard movie={m} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<h1>Nothing to show</h1>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
13
movie-search/src/common/components/NavBar.tsx
Normal file
13
movie-search/src/common/components/NavBar.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className="px-2 flex gap-4 shadow-md shadow-purple-500 mt-2 mb-4">
|
||||
<Link href="/">
|
||||
<strong>Home</strong>
|
||||
</Link>
|
||||
<Link href="/Search">Search</Link>
|
||||
<Link href="/Favorites">Favorites</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
7
movie-search/src/common/utils/searchParams.ts
Normal file
7
movie-search/src/common/utils/searchParams.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const stringifyQueryParams = (searchParams: object) =>
|
||||
Object.entries(searchParams)
|
||||
.map(
|
||||
([key, value], i, arr) =>
|
||||
`${key}=${value}${i < arr.length - 1 ? "&" : ""}`
|
||||
)
|
||||
.toString();
|
Loading…
Reference in New Issue
Block a user