Compare commits

..

No commits in common. "interview_start" and "main" have entirely different histories.

21 changed files with 523 additions and 0 deletions

64
Pagination.tsx Normal file
View 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={">>"} />
</>
);
}

View 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");
});
});

View 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();
// });
});

View 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");
});
});

View 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");
}
};

View 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} />
);
}

View File

@ -0,0 +1,10 @@
import FavoriteList from "./favoriteList";
export default function Favorites() {
return (
<div>
<h1>Your Favorites!</h1>
<FavoriteList/>
</div>
);
};

View File

@ -0,0 +1,5 @@
import Loading from "@/common/components/Loading";
export default function loading() {
return <Loading />;
}

View 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>
);
}

View 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>
);
}

View 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} />
);
}

View 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>
);
}

View File

@ -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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();