Create Repository

This commit is contained in:
Paul Hughes 2025-02-23 19:22:05 +00:00
commit 925b3cb904
46 changed files with 3959 additions and 0 deletions

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"singleQuote": false
}

36
README.md Normal file
View File

@ -0,0 +1,36 @@
# 3PL Technical Assessment
Movie Search Site
## Information
Built with ReactJS and Typescript using create vite@latest
## Hosting Locally
### Clone the repository
```
git clone https://git.paulus.casa/Paulus/tech-assessment-3pl.git && cd tech-assessment-3pl
```
### Install dependencies
```
npm install
```
### Create .env file with API address and your API Key
The file should look like this. You must prefix the key and address with VITE\_ due to the configuration of the project.
```
VITE_APIKEY=yourkey
VITE_APIADDRESS=http://www.omdbapi.com/
```
### Host Locally
```
npm run dev
```

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
<title>Moogle | Google for your movies!</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2719
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "vite-typescript-test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"prettify": "prettier --write ."
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"react-loader-spinner": "^6.1.6",
"react-router-dom": "^7.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"prettier": "3.5.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
}
}

BIN
public/error-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

76
results.json Normal file
View File

@ -0,0 +1,76 @@
{
"Search": [
{
"Title": "Inception",
"Year": "2010",
"imdbID": "tt1375666",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BMjAxMzY3NjcxNF5BMl5BanBnXkFtZTcwNTI5OTM0Mw@@._V1_SX300.jpg"
},
{
"Title": "Inception: The Cobol Job",
"Year": "2010",
"imdbID": "tt5295894",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BMjE0NGIwM2EtZjQxZi00ZTE5LWExN2MtNDBlMjY1ZmZkYjU3XkEyXkFqcGdeQXVyNjMwNzk3Mjk@._V1_SX300.jpg"
},
{
"Title": "The Crack: Inception",
"Year": "2019",
"imdbID": "tt6793710",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BZTc4MDliNjAtYmU4YS00NmQzLWEwNjktYTQ2MGFjNDc5MDhlXkEyXkFqcGc@._V1_SX300.jpg"
},
{
"Title": "Inception: Jump Right Into the Action",
"Year": "2010",
"imdbID": "tt5295990",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BZGFjOTRiYjgtYjEzMS00ZjQ2LTkzY2YtOGQ0NDI2NTVjOGFmXkEyXkFqcGdeQXVyNDQ5MDYzMTk@._V1_SX300.jpg"
},
{
"Title": "Inception: Motion Comics",
"Year": "2010",
"imdbID": "tt1790736",
"Type": "series",
"Poster": "https://m.media-amazon.com/images/M/MV5BNGRkYzkzZmEtY2YwYi00ZTlmLTgyMTctODE0NTNhNTVkZGIxXkEyXkFqcGdeQXVyNjE4MDMwMjk@._V1_SX300.jpg"
},
{
"Title": "Inception",
"Year": "2014",
"imdbID": "tt7321322",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BOTY3OGFlNTktYTJiZi00ZWMxLTk4MjQtNmJiODkxYThiNjg4XkEyXkFqcGc@._V1_SX300.jpg"
},
{
"Title": "Madness Inception",
"Year": "2022",
"imdbID": "tt29258696",
"Type": "movie",
"Poster": "N/A"
},
{
"Title": "Inception: 4Movie Premiere Special",
"Year": "2010",
"imdbID": "tt1686778",
"Type": "movie",
"Poster": "N/A"
},
{
"Title": "Cyberalien: Inception",
"Year": "2017",
"imdbID": "tt7926130",
"Type": "movie",
"Poster": "N/A"
},
{
"Title": "WWA: The Inception",
"Year": "2001",
"imdbID": "tt0311992",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BNTEyNGJjMTMtZjZhZC00ODFkLWIyYzktN2JjMTcwMmY5MDJlXkEyXkFqcGdeQXVyNDkwMzY5NjQ@._V1_SX300.jpg"
}
],
"totalResults": "38",
"Response": "True"
}

18
src/App.css Normal file
View File

@ -0,0 +1,18 @@
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#root {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media screen and (max-width: 600px) {
#root {
padding: 0;
}
}

19
src/App.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Routes, Route } from "react-router-dom";
import HomePage from "@/pages/HomePage";
import ResultsPage from "@/pages/ResultsPage";
import ErrorPage from "@/pages/ErrorPage";
import "@/App.css";
function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/results/:search/:page?" element={<ResultsPage />} />
<Route path="/:error" element={<ErrorPage />} />
</Routes>
);
}
export default App;

View File

@ -0,0 +1,5 @@
.bookmarkbutton {
position: absolute;
top: 1%;
left: 1%;
}

View File

@ -0,0 +1,29 @@
import BookmarkIcon from "@mui/icons-material/Bookmark";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import { Search } from "@/types/types";
import { useBookmarksContext } from "@/context/BookmarkContext";
import classes from "./bookmarkbutton.module.css";
interface BookmarkButtonProps {
movie: Search;
}
const BookmarkButton = ({ movie }: BookmarkButtonProps) => {
const { toggleBookmark, isBookmark } = useBookmarksContext();
return (
<div
className={classes.bookmarkbutton}
onClick={() => {
toggleBookmark(movie);
}}
>
{" "}
{isBookmark(movie) ? <BookmarkIcon /> : <BookmarkBorderIcon />}
</div>
);
};
export default BookmarkButton;

View File

@ -0,0 +1,32 @@
.imgcontainer {
display: flex;
justify-content: center;
align-items: center;
height: 45%;
}
.image {
height: 100%;
max-width: 80%;
aspect-ratio: auto;
margin: 0 auto 2rem;
}
.subheading {
font-size: 1.1rem;
line-height: 1.3rem;
min-height: 2.6rem;
margin: 0.5rem auto;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
}
.year {
font-size: 1rem;
text-align: center;
}

View File

@ -0,0 +1,39 @@
import { MovieDetails, Search } from "@/types/types";
import BookmarkButton from "@/components/BookmarkButton";
import classes from "./fullmoviecard.module.css";
interface FullMovieCardProps {
movie: MovieDetails;
searchResult: Search;
handleImageError: (event: React.SyntheticEvent<HTMLImageElement>) => void;
}
const FullMovieCard = ({
movie,
handleImageError,
searchResult,
}: FullMovieCardProps) => {
return (
<>
<BookmarkButton movie={searchResult} />
<div className={classes.imgcontainer}>
<img
className={classes.image}
src={movie.Poster}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
handleImageError(e);
}}
alt={`${movie.Title} Poster`}
/>
</div>
<h3 className={classes.subheading}>{movie.Title}</h3>
<p className={classes.year}>Year: {movie.Year}</p>
<p className={classes.year}>Directed By: {movie.Director}</p>
<p className={classes.year}>Plot: {movie.Plot}</p>
<p className={classes.year}>IMDB Rating: {movie.imdbRating}</p>
</>
);
};
export default FullMovieCard;

View File

@ -0,0 +1,20 @@
import { RotatingLines } from "react-loader-spinner";
import classes from "./loading.module.css";
const Loading = () => {
return (
<div className={classes.container}>
<RotatingLines
visible={true}
strokeColor="#000000"
strokeWidth="5"
animationDuration="0.75"
ariaLabel="rotating-lines-loading"
/>
<h2 className={classes.text}>Loading...........</h2>
</div>
);
};
export default Loading;

View File

@ -0,0 +1,12 @@
.container {
height: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
align-items: center;
justify-content: center;
}
.text {
font-size: 2rem;
}

View File

@ -0,0 +1,18 @@
import classes from "./moogle.module.css";
const Moogle = () => {
return (
<h1 className={classes.title}>
<a href="/" className={classes.link}>
<span className={classes.purple}>M</span>
<span className={classes.green}>o</span>
<span className={classes.orange}>o</span>
<span className={classes.blue}>g</span>
<span className={classes.green}>l</span>
<span className={classes.red}>e</span>
</a>
</h1>
);
};
export default Moogle;

View File

@ -0,0 +1,29 @@
.title {
text-wrap: nowrap;
font-size: 4rem;
margin: 0.25rem;
}
.link {
text-decoration: none;
}
.green {
color: green;
}
.yellow {
color: yellow;
}
.purple {
color: purple;
}
.orange {
color: orange;
}
.blue {
color: blue;
}

View File

@ -0,0 +1,44 @@
import { Search } from "@/types/types";
import BookmarkButton from "@/components/BookmarkButton";
import classes from "./moviecard.module.css";
interface MovieCardProps {
movie: Search;
openHandler: (arg: string | undefined, arg2: Search) => void;
handleImageError: (event: React.SyntheticEvent<HTMLImageElement>) => void;
}
const MovieCard = ({
movie,
openHandler,
handleImageError,
}: MovieCardProps) => {
return (
<>
<BookmarkButton movie={movie} />
<div className={classes.imgcontainer}>
<img
className={classes.image}
src={movie.Poster}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
handleImageError(e);
}}
alt={`${movie.Title} Poster`}
/>
</div>
<p className={classes.year}>{movie.Year}</p>
<h3 className={classes.subheading}>{movie.Title}</h3>
<button
onClick={() => {
openHandler(movie.imdbID, movie);
}}
className={classes.button}
>
More Information
</button>
</>
);
};
export default MovieCard;

View File

@ -0,0 +1,37 @@
.imgcontainer {
display: flex;
justify-content: center;
align-items: center;
height: 65%;
}
.image {
height: 100%;
max-width: 80%;
aspect-ratio: auto;
margin: 0 auto;
}
.subheading {
font-size: 1.1rem;
line-height: 1.3rem;
min-height: 2.6rem;
margin: 0.5rem auto;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
}
.year {
font-size: 1rem;
text-align: center;
}
.button {
padding: 0.25rem 2rem;
border-radius: 10px;
}

View File

@ -0,0 +1,85 @@
import Modal from "@mui/material/Modal";
import CloseIcon from "@mui/icons-material/Close";
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import Loading from "@/components/Loading";
import { MovieDetails, Search } from "@/types/types";
import FullMovieCard from "@/components/FullMovieCard";
import classes from "./moviemodal.module.css";
interface MovieModalProps {
id: string | undefined;
open: boolean;
searchResult: Search | undefined;
closeHandler: () => void;
handleImageError: (event: React.SyntheticEvent<HTMLImageElement>) => void;
}
type MovieResult = MovieDetails | null;
const MovieModal = ({
open,
closeHandler,
id,
handleImageError,
searchResult,
}: MovieModalProps) => {
const APIADDRESS = import.meta.env.VITE_APIADDRESS;
const APIKEY = import.meta.env.VITE_APIKEY;
const [movieResult, setMovieResult] = useState<MovieResult>(null);
async function handleGetMovieResult() {
const endpoint = `${APIADDRESS}?i=${id}&apikey=${APIKEY}`;
const reply = await fetch(`${endpoint}`);
const data = await reply.json();
setMovieResult(data);
}
const location = useLocation();
useEffect(() => {
handleGetMovieResult();
}, [location, id]);
return (
<>
<Modal
open={open}
onClose={closeHandler}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
disableScrollLock={true}
sx={{ border: "none" }}
>
<div className={classes.modal}>
<div className={classes.container}>
<button className={classes.closebutton} onClick={closeHandler}>
<CloseIcon />
</button>
{
movieResult === null ? (
<Loading />
) : movieResult?.imdbID !== id ? (
<Loading />
) : searchResult !== undefined ? (
<FullMovieCard
movie={movieResult}
searchResult={searchResult}
handleImageError={handleImageError}
/>
) : (
<></>
) //I know here that the movie will display
}
</div>
</div>
</Modal>
</>
);
};
export default MovieModal;

View File

@ -0,0 +1,28 @@
.modal {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #000000a6;
}
.container {
position: relative;
border: 2px solid #000000;
padding: 1rem;
border-radius: 10px;
width: clamp(350px, 75%, 1200px);
height: clamp(350px, 75%, 1200px);
background: #ffffff;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.closebutton {
position: absolute;
top: 1%;
right: 1%;
}

View File

@ -0,0 +1,22 @@
import Moogle from "@/components/Moogle";
import SearchForm from "@/components/SearchForm";
import classes from "./noresults.module.css";
interface NoResultsProps {
searchQuery: string | undefined;
}
const NoResults = ({ searchQuery }: NoResultsProps) => {
return (
<div className={classes.container}>
<Moogle />
<h2 className={classes.text}>
No results for {`${searchQuery}`} were found!
</h2>
<SearchForm layout="Vertical" />
</div>
);
};
export default NoResults;

View File

@ -0,0 +1,14 @@
.container {
height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.text {
margin: 0rem;
padding: 0.5rem;
font-size: 1.5rem;
}

View File

@ -0,0 +1,69 @@
import classes from "./pagination.module.css";
interface PaginationProps {
searchQuery: string | undefined;
page: string | undefined;
maxResults: string;
}
const Pagination = ({ searchQuery, page, maxResults }: PaginationProps) => {
const pageToNumber = (Page: PaginationProps["page"]): number => {
if (Page === undefined) {
return 1;
} else {
return parseInt(Page);
}
};
// According to API docs the highest value is 100
const getPages = (
page: PaginationProps["page"],
maxResults: PaginationProps["maxResults"],
): number[] => {
const pageNumber = pageToNumber(page);
const pageNumbers: number[] = [];
const maxPageNumber = pageToNumber(maxResults) / 10;
let startPage = pageNumber - 2;
if (startPage < 3) {
startPage = 1;
}
if (startPage > 98) {
startPage = 98;
}
if (startPage > maxPageNumber - 4) {
startPage = Math.max(1, maxPageNumber - 4);
}
for (let i = 0; i < 5; i++) {
if (startPage + i <= maxPageNumber) {
pageNumbers.push(startPage + i);
}
}
return pageNumbers;
};
const pages = getPages(page, maxResults);
return (
<div className={classes.container}>
{pages.map((num) => {
return (
<a
key={num}
className={classes.link}
href={`/results/${searchQuery}/${num}`}
>
{num}
</a>
);
})}
</div>
);
};
export default Pagination;

View File

@ -0,0 +1,8 @@
.container {
padding: 2rem 0;
}
.link {
font-size: 1.2rem;
padding: 0 1rem;
}

View File

@ -0,0 +1,49 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import classes from "./searchform.module.css";
interface SearchFormProps {
layout: string;
}
const SearchForm = ({ layout }: SearchFormProps) => {
const navigate = useNavigate();
const [search, setSearch] = useState<string>("");
function handleChange(event: React.FormEvent<HTMLInputElement>) {
const element = event.currentTarget as HTMLInputElement;
const value = element.value;
setSearch(value);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const redirect = `/results/${search}`;
navigate(redirect);
}
return (
<div>
<form
className={
layout === "Horizontal"
? `${classes.form} ${classes.horizontal}`
: classes.form
}
onSubmit={(event) => handleSubmit(event)}
>
<input
className={classes.input}
type="text"
name="searchterm"
value={search}
onChange={handleChange}
/>
<button className={classes.button}>Search</button>
</form>
</div>
);
};
export default SearchForm;

View File

@ -0,0 +1,24 @@
.form {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 0.5rem 2rem;
flex-direction: column;
}
.horizontal {
flex-direction: row;
}
.input {
font-size: 1.5rem;
border-radius: 10px;
padding: 0.5rem;
}
.button {
font-size: 1.2rem;
padding: 0.5rem 2rem;
border-radius: 0.5rem;
}

View File

@ -0,0 +1,79 @@
import MovieCard from "@/components/MovieCard";
import { Search, ResultsApi } from "@/types/types";
import Pagination from "@/components/Pagination";
import Moogle from "@/components/Moogle";
import SearchForm from "@/components/SearchForm";
import MovieModal from "@/components/MovieModal";
import classes from "./searchresults.module.css";
interface SearchResultsProps {
results: ResultsApi;
id: string | undefined;
searchQuery: string | undefined;
page: string | undefined;
open: boolean;
movieSelected: Search | undefined;
openHandler: (arg: string | undefined, arg2: Search) => void;
closeHandler: () => void;
}
const SearchResults = ({
results,
searchQuery,
page,
open,
openHandler,
closeHandler,
id,
movieSelected,
}: SearchResultsProps) => {
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget as HTMLImageElement;
element.src = "/error-image.jpg";
};
return (
<>
<div className={classes.topbar}>
<Moogle />
<SearchForm layout="Horizontal" />
</div>
<div className={classes.container}>
<h2 className={classes.title}>
Search Results for {`\"${searchQuery}\"`}
</h2>
<div className={classes.subcontainer}>
{results.Search.map((element: Search, index: number) => {
return (
<div
className={classes.moviecontainer}
key={`element.Title${index}`}
>
<MovieCard
movie={element}
openHandler={openHandler}
handleImageError={handleImageError}
/>
</div>
);
})}
</div>
<Pagination
searchQuery={searchQuery}
page={page}
maxResults={results.totalResults}
/>
<MovieModal
open={open}
closeHandler={closeHandler}
id={id}
handleImageError={handleImageError}
searchResult={movieSelected}
/>
</div>
</>
);
};
export default SearchResults;

View File

@ -0,0 +1,31 @@
.topbar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 6rem;
}
.title {
font-size: 2rem;
}
.container {
padding: 1rem;
}
.subcontainer {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
max-width: 1000px;
margin: 0 auto;
gap: 2rem;
}
.moviecontainer {
position: relative;
height: 40vh;
background: #fff2cc;
padding: 1rem;
border: 1px solid black;
}

View File

@ -0,0 +1,56 @@
import {
createContext,
useState,
useContext,
ReactNode,
} from "react";
import { Search } from "@/types/types";
type BookmarkContextType = {
bookmarks: Search[];
toggleBookmark: (item: Search) => void;
isBookmark: (movie: Search) => boolean;
};
type BookmarkProviderProps = {
children: ReactNode;
};
const BookmarkContext = createContext<BookmarkContextType | undefined>(
undefined,
);
const BookmarkContextProvider = ({ children }: BookmarkProviderProps) => {
const [bookmarks, setBookmarks] = useState<Search[]>(() => {
return JSON.parse(localStorage.getItem("Bookmarks") || "[]");
});
const toggleBookmark = (movie: Search) => {
const newBookmarks = isBookmark(movie)
? bookmarks.filter((item) => item.imdbID !== movie.imdbID)
: [...bookmarks, movie];
setBookmarks(newBookmarks);
localStorage.setItem("Bookmarks", JSON.stringify(newBookmarks));
};
const isBookmark = (movie: Search) => {
return bookmarks.some((item) => item.imdbID === movie.imdbID);
};
return (
<BookmarkContext.Provider value={{ bookmarks, toggleBookmark, isBookmark }}>
{children}
</BookmarkContext.Provider>
);
};
export default BookmarkContextProvider;
export const useBookmarksContext = () => {
const context = useContext(BookmarkContext);
if (!context) {
throw new Error("No Provider for useBookmarks");
}
return context;
};

16
src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import BookmarkContextProvider from "@/context/BookmarkContext";
import App from "@/App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<BookmarkContextProvider>
<App />
</BookmarkContextProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@ -0,0 +1,12 @@
.container {
height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.text {
font-size: 2rem;
}

View File

@ -0,0 +1,17 @@
import Moogle from "@/components/Moogle";
import SearchForm from "@/components/SearchForm";
import classes from "./errorpage.module.css";
const ErrorPage = () => {
return (
<div className={classes.container}>
<Moogle />
<div className={classes.text}>404</div>
<div className={classes.text}>This Page Does Not Exist</div>
<SearchForm layout="Vertical" />
</div>
);
};
export default ErrorPage;

View File

@ -0,0 +1,8 @@
.container {
height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}

View File

@ -0,0 +1,15 @@
import Moogle from "@/components/Moogle";
import SearchForm from "@/components/SearchForm";
import classes from "./homepage.module.css";
const HomePage = () => {
return (
<div className={classes.container}>
<Moogle />
<SearchForm layout="Vertical" />
</div>
);
};
export default HomePage;

View File

@ -0,0 +1,75 @@
import { useParams, useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import { ResultsApi, Search } from "@/types/types";
import Loading from "@/components/Loading";
import NoResults from "@/components/NoResults";
import SearchResults from "@/components/SearchResults";
import classes from "./resultspage.module.css";
type Results = ResultsApi | undefined;
const ResultsPage = () => {
const APIADDRESS = import.meta.env.VITE_APIADDRESS;
const APIKEY = import.meta.env.VITE_APIKEY;
//Data for API Call and Result
const { search, page } = useParams();
const [results, setResults] = useState<Results>();
const location = useLocation();
//Handlers for Movie Modal Opening and Closing
const [open, setOpen] = useState<boolean>(false);
const [id, setId] = useState<string | undefined>("");
const [movieSelected, setMovieSelected] = useState<Search | undefined>();
const handleOpen = (ID: string | undefined, movie: Search) => {
setOpen(true);
setId(ID);
setMovieSelected(movie);
};
const handleClose = () => setOpen(false);
async function handleGetResults() {
let endpoint: string = "";
if (page === "0" || page === undefined) {
//default is 1 anyway but just in case someone wants to change parameters
endpoint = `${APIADDRESS}?s=${search}&apikey=${APIKEY}`;
} else {
endpoint = `${APIADDRESS}?s=${search}&page=${page}&apikey=${APIKEY}`;
}
const reply = await fetch(`${endpoint}`);
const data = await reply.json();
setResults(data);
}
useEffect(() => {
handleGetResults();
}, [location]);
return (
<div className={classes.container}>
{results === undefined ? (
<Loading />
) : results.Response === "False" ? (
<NoResults searchQuery={search} />
) : (
<SearchResults
searchQuery={search}
results={results}
page={page}
open={open}
openHandler={handleOpen}
closeHandler={handleClose}
id={id}
movieSelected={movieSelected}
/>
)}
</div>
);
};
export default ResultsPage;

View File

@ -0,0 +1,7 @@
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

53
src/types/types.ts Normal file
View File

@ -0,0 +1,53 @@
/* --- Thanks to "https://quicktype.io" --- */
export interface ResultsApi {
Search: Search[];
totalResults: string;
Response: string;
}
export interface Search {
Title: string;
Year: string;
imdbID: string;
Type: Type;
Poster: string;
}
export enum Type {
Movie = "movie",
Series = "series",
}
export interface MovieDetails {
Title: string;
Year: string;
Rated: string;
Released: string;
Runtime: string;
Genre: string;
Director: string;
Writer: string;
Actors: string;
Plot: string;
Language: string;
Country: string;
Awards: string;
Poster: string;
Ratings: Rating[];
Metascore: string;
imdbRating: string;
imdbVotes: string;
imdbID: string;
Type: string;
DVD: string;
BoxOffice: string;
Production: string;
Website: string;
Response: string;
}
export interface Rating {
Source: string;
Value: string;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

1
testapi.sh Executable file
View File

@ -0,0 +1 @@
curl -X GET 'http://omdbapi.com/?s=inception&apikey=APIKEY' >> "results.json"

30
tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [react()],
});