Create Repository
This commit is contained in:
commit
925b3cb904
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false
|
||||
}
|
||||
36
README.md
Normal file
36
README.md
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
2719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
BIN
public/error-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
public/favicon.ico
Executable file
BIN
public/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
76
results.json
Normal file
76
results.json
Normal 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
18
src/App.css
Normal 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
19
src/App.tsx
Normal 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;
|
||||
5
src/components/BookmarkButton/bookmarkbutton.module.css
Normal file
5
src/components/BookmarkButton/bookmarkbutton.module.css
Normal file
@ -0,0 +1,5 @@
|
||||
.bookmarkbutton {
|
||||
position: absolute;
|
||||
top: 1%;
|
||||
left: 1%;
|
||||
}
|
||||
29
src/components/BookmarkButton/index.tsx
Normal file
29
src/components/BookmarkButton/index.tsx
Normal 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;
|
||||
32
src/components/FullMovieCard/fullmoviecard.module.css
Normal file
32
src/components/FullMovieCard/fullmoviecard.module.css
Normal 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;
|
||||
}
|
||||
39
src/components/FullMovieCard/index.tsx
Normal file
39
src/components/FullMovieCard/index.tsx
Normal 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;
|
||||
20
src/components/Loading/index.tsx
Normal file
20
src/components/Loading/index.tsx
Normal 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;
|
||||
12
src/components/Loading/loading.module.css
Normal file
12
src/components/Loading/loading.module.css
Normal 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;
|
||||
}
|
||||
18
src/components/Moogle/index.tsx
Normal file
18
src/components/Moogle/index.tsx
Normal 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;
|
||||
29
src/components/Moogle/moogle.module.css
Normal file
29
src/components/Moogle/moogle.module.css
Normal 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;
|
||||
}
|
||||
44
src/components/MovieCard/index.tsx
Normal file
44
src/components/MovieCard/index.tsx
Normal 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;
|
||||
37
src/components/MovieCard/moviecard.module.css
Normal file
37
src/components/MovieCard/moviecard.module.css
Normal 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;
|
||||
}
|
||||
85
src/components/MovieModal/index.tsx
Normal file
85
src/components/MovieModal/index.tsx
Normal 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;
|
||||
28
src/components/MovieModal/moviemodal.module.css
Normal file
28
src/components/MovieModal/moviemodal.module.css
Normal 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%;
|
||||
}
|
||||
22
src/components/NoResults/index.tsx
Normal file
22
src/components/NoResults/index.tsx
Normal 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;
|
||||
14
src/components/NoResults/noresults.module.css
Normal file
14
src/components/NoResults/noresults.module.css
Normal 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;
|
||||
}
|
||||
69
src/components/Pagination/index.tsx
Normal file
69
src/components/Pagination/index.tsx
Normal 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;
|
||||
8
src/components/Pagination/pagination.module.css
Normal file
8
src/components/Pagination/pagination.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.container {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 1.2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
49
src/components/SearchForm/index.tsx
Normal file
49
src/components/SearchForm/index.tsx
Normal 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;
|
||||
24
src/components/SearchForm/searchform.module.css
Normal file
24
src/components/SearchForm/searchform.module.css
Normal 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;
|
||||
}
|
||||
79
src/components/SearchResults/index.tsx
Normal file
79
src/components/SearchResults/index.tsx
Normal 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;
|
||||
31
src/components/SearchResults/searchresults.module.css
Normal file
31
src/components/SearchResults/searchresults.module.css
Normal 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;
|
||||
}
|
||||
56
src/context/BookmarkContext.tsx
Normal file
56
src/context/BookmarkContext.tsx
Normal 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
16
src/main.tsx
Normal 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>,
|
||||
);
|
||||
12
src/pages/ErrorPage/errorpage.module.css
Normal file
12
src/pages/ErrorPage/errorpage.module.css
Normal 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;
|
||||
}
|
||||
17
src/pages/ErrorPage/index.tsx
Normal file
17
src/pages/ErrorPage/index.tsx
Normal 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;
|
||||
8
src/pages/HomePage/homepage.module.css
Normal file
8
src/pages/HomePage/homepage.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.container {
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
15
src/pages/HomePage/index.tsx
Normal file
15
src/pages/HomePage/index.tsx
Normal 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;
|
||||
75
src/pages/ResultsPage/index.tsx
Normal file
75
src/pages/ResultsPage/index.tsx
Normal 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;
|
||||
7
src/pages/ResultsPage/resultspage.module.css
Normal file
7
src/pages/ResultsPage/resultspage.module.css
Normal 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
53
src/types/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
1
testapi.sh
Executable file
1
testapi.sh
Executable file
@ -0,0 +1 @@
|
||||
curl -X GET 'http://omdbapi.com/?s=inception&apikey=APIKEY' >> "results.json"
|
||||
30
tsconfig.app.json
Normal file
30
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal 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
12
vite.config.ts
Normal 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()],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user