diff --git a/jobs/Frontend/.env b/jobs/Frontend/.env new file mode 100644 index 000000000..b55d2320a --- /dev/null +++ b/jobs/Frontend/.env @@ -0,0 +1,2 @@ +VITE_API_KEY=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJlNTFiMjAxNzAxNmE4MGVkMGUyNTliMzRjNTE4ZDlmOCIsIm5iZiI6MTYyMDY1MDYyNC4xNjQsInN1YiI6IjYwOTkyYTgwYTNkMDI3MDAzYTQxN2NhNiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ._BphAcNuhdL3QtSAGoBNuEde6dfIdiV8BXLG_cMIBP4 +VITE_API_URL=https://api.themoviedb.org/3 \ No newline at end of file diff --git a/jobs/Frontend/.gitignore b/jobs/Frontend/.gitignore new file mode 100644 index 000000000..54f07af58 --- /dev/null +++ b/jobs/Frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/jobs/Frontend/Readme.md b/jobs/Frontend/Readme.md index cf1f19543..180e922fa 100644 --- a/jobs/Frontend/Readme.md +++ b/jobs/Frontend/Readme.md @@ -1,16 +1,30 @@ -# Mews frontend developer task +# MovieFan application where you can search the movie you want to know more about -You should start with creating a fork of the repository. When you're finished with the task, you should create a pull request. +To start the dev server: -Your task will be to create a simple movie search application. The application will have 2 views - search and movie detail. The search view is the default view, and should contain search input and display paginated list of found movies with a way to load additional batch. Search should start automatically after typing into the input is finished - there is no need for a search button. Clicking on a movie gets you to the movie detail view where detailed information about the movie should be listed. +- update the dependecies with `yarn` +- perform `yarn dev` after that -To retrieve information about movies, use [TheMovieDb API](https://developers.themoviedb.org/3/getting-started/introduction). You can use our api key to authorize requests: -``` -03b8572954325680265531140190fd2a -``` +To check the production: -## Required technologies +- run `yarn build` +- then `yarn preview` -To test your proficiency with the technologies we use the most, we require the solution to be written in React and TypeScript. -We use styled-components as our main CSS-in-JS framework, yet feel free to use other solutions you are more familiar with. -The use of any additional library is allowed and up to you. +To check unit tests: + +- run `yarn test` +- or `yarn test:ci` for coverage + +To check linting: + +- run `yarn lint` + +## More about my process of thinking when developing it + +Besides required `React` and `TS`: + +- Thought to not use any 3rd party state management for such a small project. It was enough for me to work with `useState()` and `useContext()` and to have a more lightweight bundle after all. +- Wanted to try out `Vite` bundler due to it's popularity, modern approach and cool features like per route bundling and prebundling deps. +- Decided to style everything with `styled-components` as your company's main styling tool. +- Configured `eslint/tslint` for the project, used `prettier` also. +- For unit testing I went with `jest` and `react-testing-library`. Covered a couple of tests to show my approach to that. diff --git a/jobs/Frontend/eslint.config.js b/jobs/Frontend/eslint.config.js new file mode 100644 index 000000000..a29eded99 --- /dev/null +++ b/jobs/Frontend/eslint.config.js @@ -0,0 +1,36 @@ +import globals from "globals"; +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import react from "eslint-plugin-react"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + settings: { react: { version: "18.3" } }, + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + project: ["./tsconfig.node.json", "./tsconfig.app.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + react: react, + }, + rules: { + ...reactHooks.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + } +); diff --git a/jobs/Frontend/index.html b/jobs/Frontend/index.html new file mode 100644 index 000000000..dca84576f --- /dev/null +++ b/jobs/Frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MovieFan + + +
+ + + diff --git a/jobs/Frontend/jest.config.js b/jobs/Frontend/jest.config.js new file mode 100644 index 000000000..aed37e393 --- /dev/null +++ b/jobs/Frontend/jest.config.js @@ -0,0 +1,20 @@ +export default { + testEnvironment: "jsdom", + extensionsToTreatAsEsm: [".ts", ".tsx"], + transform: { + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + useESM: true, + tsconfig: "tsconfig.json", + }, + ], + }, + moduleNameMapper: { + "@/(.*)": "/src/$1", + ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": + "identity-obj-proxy", + }, + testMatch: ["/src/**/*.test.(ts|tsx)"], + preset: "ts-jest", +}; diff --git a/jobs/Frontend/package.json b/jobs/Frontend/package.json new file mode 100644 index 000000000..dd6758995 --- /dev/null +++ b/jobs/Frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "movies-search", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "jest", + "test:ci": "jest --coverage" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.0.2", + "styled-components": "^6.1.13" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.15.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.1" + } +} diff --git a/jobs/Frontend/public/movie-fan.svg b/jobs/Frontend/public/movie-fan.svg new file mode 100644 index 000000000..544deb388 --- /dev/null +++ b/jobs/Frontend/public/movie-fan.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Frontend/src/App.tsx b/jobs/Frontend/src/App.tsx new file mode 100644 index 000000000..6bf3d1e94 --- /dev/null +++ b/jobs/Frontend/src/App.tsx @@ -0,0 +1,23 @@ +import { ThemeProvider } from "styled-components"; +import MainLayout from "@/layouts/Main"; +import Routing from "@/providers/Routing"; +import theme from "@/styles/theme"; +import GlobalStyles from "@/styles/global"; +import ErrorCatcher from "@/providers/ErrorCatcher"; +import ErrorFlash from "@/components/ErrorFlash/ErrorFlash"; + +function App() { + return ( + + + + + + + + + + ); +} + +export default App; diff --git a/jobs/Frontend/src/assets/movie-backdrop.jpg b/jobs/Frontend/src/assets/movie-backdrop.jpg new file mode 100644 index 000000000..9db30ed84 Binary files /dev/null and b/jobs/Frontend/src/assets/movie-backdrop.jpg differ diff --git a/jobs/Frontend/src/assets/movie-default.png b/jobs/Frontend/src/assets/movie-default.png new file mode 100644 index 000000000..7e6241b76 Binary files /dev/null and b/jobs/Frontend/src/assets/movie-default.png differ diff --git a/jobs/Frontend/src/components/ErrorFlash/ErrorFlash.tsx b/jobs/Frontend/src/components/ErrorFlash/ErrorFlash.tsx new file mode 100644 index 000000000..97c11d668 --- /dev/null +++ b/jobs/Frontend/src/components/ErrorFlash/ErrorFlash.tsx @@ -0,0 +1,22 @@ +import { useContext } from "react"; +import { + ErrorContainer, + ErrorMessage, + CloseButton, +} from "@/components/ErrorFlash/ErrorFlashStyle"; +import ErrorContext from "@/providers/ErrorContext"; + +const ErrorFlash: React.FC = () => { + const { message, setMessage } = useContext(ErrorContext); + + return ( + message && ( + + {message} + setMessage("")}>× + + ) + ); +}; + +export default ErrorFlash; diff --git a/jobs/Frontend/src/components/ErrorFlash/ErrorFlashStyle.ts b/jobs/Frontend/src/components/ErrorFlash/ErrorFlashStyle.ts new file mode 100644 index 000000000..7853dd406 --- /dev/null +++ b/jobs/Frontend/src/components/ErrorFlash/ErrorFlashStyle.ts @@ -0,0 +1,36 @@ +import styled from "styled-components"; + +export const ErrorContainer = styled.div` + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 0, 0, 0.8); + color: #fff; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)}; + border-radius: 4px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; + z-index: 9999; +`; + +export const ErrorMessage = styled.div` + font-size: 1rem; + font-weight: 500; +`; + +export const CloseButton = styled.button` + background: none; + border: none; + color: #fff; + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + padding: 8px; + + &:hover { + opacity: 0.8; + } +`; diff --git a/jobs/Frontend/src/components/Footer/Footer.tsx b/jobs/Frontend/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..791b41869 --- /dev/null +++ b/jobs/Frontend/src/components/Footer/Footer.tsx @@ -0,0 +1,7 @@ +import { Footer as StyledFooter } from "@/components/Footer/FooterStyle"; + +const Footer: React.FC = () => { + return Made by Daniel; +}; + +export default Footer; diff --git a/jobs/Frontend/src/components/Footer/FooterStyle.ts b/jobs/Frontend/src/components/Footer/FooterStyle.ts new file mode 100644 index 000000000..836a42629 --- /dev/null +++ b/jobs/Frontend/src/components/Footer/FooterStyle.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const Footer = styled.footer` + margin-top: auto; + padding: ${({ theme }) => theme.spacing(2)}; + text-align: center; + background: transparent; + color: ${({ theme }) => theme.colors.textSecondary}; + font-size: 0.9rem; +`; diff --git a/jobs/Frontend/src/components/Header/Header.tsx b/jobs/Frontend/src/components/Header/Header.tsx new file mode 100644 index 000000000..13ccbfe89 --- /dev/null +++ b/jobs/Frontend/src/components/Header/Header.tsx @@ -0,0 +1,14 @@ +import { + AppName, + Header as StyledHeader, +} from "@/components/Header/HeaderStyle"; + +const Header: React.FC = () => { + return ( + + MovieFan + + ); +}; + +export default Header; diff --git a/jobs/Frontend/src/components/Header/HeaderStyle.ts b/jobs/Frontend/src/components/Header/HeaderStyle.ts new file mode 100644 index 000000000..4f49d4cc7 --- /dev/null +++ b/jobs/Frontend/src/components/Header/HeaderStyle.ts @@ -0,0 +1,18 @@ +import styled from "styled-components"; + +export const Header = styled.header` + width: 100%; + background: transparent; + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + padding: ${({ theme }) => theme.spacing(2)} 0; +`; + +export const AppName = styled.h1` + margin: 0; + font-size: 1.8rem; + font-weight: 700; + color: ${({ theme }) => theme.colors.textPrimary}; +`; diff --git a/jobs/Frontend/src/components/MovieCard/MovieCard.tsx b/jobs/Frontend/src/components/MovieCard/MovieCard.tsx new file mode 100644 index 000000000..01d29500b --- /dev/null +++ b/jobs/Frontend/src/components/MovieCard/MovieCard.tsx @@ -0,0 +1,39 @@ +import { Link } from "react-router"; +import movieDefault from "@/assets/movie-default.png"; +import { + MovieCardContainer, + MovieInfo, + SubInfo, + Poster, + Title, +} from "@/components/MovieCard/MovieCardStyle"; +import { Movie } from "@/types/movies"; + +const MovieCard: React.FC = ({ + title, + poster_path, + release_date, + vote_average, + id, +}) => { + const imgSrc = poster_path + ? `https://image.tmdb.org/t/p/w500${poster_path}` + : movieDefault; + + return ( + + + + + {title} + + {release_date} + {vote_average?.toFixed(1)} ★ + + + + + ); +}; + +export default MovieCard; diff --git a/jobs/Frontend/src/components/MovieCard/MovieCardStyle.ts b/jobs/Frontend/src/components/MovieCard/MovieCardStyle.ts new file mode 100644 index 000000000..08bdf5595 --- /dev/null +++ b/jobs/Frontend/src/components/MovieCard/MovieCardStyle.ts @@ -0,0 +1,40 @@ +import styled from "styled-components"; + +export const MovieCardContainer = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 8px; + overflow: hidden; + transition: transform 0.3s ease, box-shadow 0.3s ease; + display: flex; + flex-direction: column; + cursor: pointer; + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); + } +`; + +export const Poster = styled.img` + width: 100%; + height: auto; + display: block; +`; + +export const MovieInfo = styled.div` + padding: ${({ theme }) => theme.spacing(2)}; +`; + +export const Title = styled.h2` + margin: 0 0 ${({ theme }) => theme.spacing(1)} 0; + font-size: 1rem; + font-weight: 600; + color: ${({ theme }) => theme.colors.textPrimary}; +`; + +export const SubInfo = styled.div` + font-size: 0.85rem; + color: ${({ theme }) => theme.colors.textSecondary}; + display: flex; + justify-content: space-between; + align-items: center; +`; diff --git a/jobs/Frontend/src/components/NoMovieFound/NoMovieFound.tsx b/jobs/Frontend/src/components/NoMovieFound/NoMovieFound.tsx new file mode 100644 index 000000000..c8cf51445 --- /dev/null +++ b/jobs/Frontend/src/components/NoMovieFound/NoMovieFound.tsx @@ -0,0 +1,26 @@ +import { Link } from "react-router"; +import { + NoSuchMovieContainer, + NoSuchMovieIcon, + NoSuchMovieMessage, +} from "@/components/NoMovieFound/NoMovieFoundStyle"; +import { BackButton } from "@/pages/MovieDetails/MovieDetailsStyle"; + +interface NoMovieFoundProps { + message?: string; +} +const NoMovieFound: React.FC = ({ + message = "No such movie, sorry", +}) => { + return ( + + 😥 + {message} + + ← Back to Search + + + ); +}; + +export default NoMovieFound; diff --git a/jobs/Frontend/src/components/NoMovieFound/NoMovieFoundStyle.ts b/jobs/Frontend/src/components/NoMovieFound/NoMovieFoundStyle.ts new file mode 100644 index 000000000..4ea723854 --- /dev/null +++ b/jobs/Frontend/src/components/NoMovieFound/NoMovieFoundStyle.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const NoSuchMovieContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: ${({ theme }) => theme.colors.textPrimary}; + padding: ${({ theme }) => theme.spacing(4)}; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +export const NoSuchMovieIcon = styled.div` + font-size: 3rem; +`; + +export const NoSuchMovieMessage = styled.div` + font-size: 1.2rem; + font-weight: 500; +`; diff --git a/jobs/Frontend/src/components/NoResults/NoResults.tsx b/jobs/Frontend/src/components/NoResults/NoResults.tsx new file mode 100644 index 000000000..3977a8631 --- /dev/null +++ b/jobs/Frontend/src/components/NoResults/NoResults.tsx @@ -0,0 +1,16 @@ +import { + NoResultsContainer, + NoResultsIcon, + NoResultsMessage, +} from "@/components/NoResults/NoResultsStyle"; + +function NoResults({ message = "Try adjusting your search..." }) { + return ( + + 🔍 + {message} + + ); +} + +export default NoResults; diff --git a/jobs/Frontend/src/components/NoResults/NoResultsStyle.ts b/jobs/Frontend/src/components/NoResults/NoResultsStyle.ts new file mode 100644 index 000000000..bc68c5b81 --- /dev/null +++ b/jobs/Frontend/src/components/NoResults/NoResultsStyle.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const NoResultsContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: ${({ theme }) => theme.colors.textPrimary}; + padding: ${({ theme }) => theme.spacing(4)}; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +export const NoResultsIcon = styled.div` + font-size: 3rem; +`; + +export const NoResultsMessage = styled.div` + font-size: 1.2rem; + font-weight: 500; +`; diff --git a/jobs/Frontend/src/components/SearchBar/SearchBar.tsx b/jobs/Frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 000000000..92c2e9cc4 --- /dev/null +++ b/jobs/Frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,16 @@ +import { SearchBarContainer, SearchInput } from "./SearchBarStyle"; + +const SearchBar: React.FC< + React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > +> = (props) => { + return ( + + + + ); +}; + +export default SearchBar; diff --git a/jobs/Frontend/src/components/SearchBar/SearchBarStyle.ts b/jobs/Frontend/src/components/SearchBar/SearchBarStyle.ts new file mode 100644 index 000000000..2d3709cb2 --- /dev/null +++ b/jobs/Frontend/src/components/SearchBar/SearchBarStyle.ts @@ -0,0 +1,33 @@ +import styled from "styled-components"; + +export const SearchBarContainer = styled.div` + position: relative; + width: 90%; + max-width: 400px; + margin: auto; + + @media (min-width: 768px) { + max-width: 600px; + } +`; + +export const SearchInput = styled.input` + width: 100%; + padding: ${({ theme }) => theme.spacing(1.5)} + ${({ theme }) => theme.spacing(2)}; + border-radius: 25px; + border: none; + outline: none; + background: rgba(255, 255, 255, 0.15); + color: ${({ theme }) => theme.colors.textPrimary}; + font-size: 1rem; + + &::placeholder { + color: ${({ theme }) => theme.colors.textSecondary}; + font-size: 1rem; + } + + &:focus { + background: rgba(255, 255, 255, 0.25); + } +`; diff --git a/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetails.tsx b/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetails.tsx new file mode 100644 index 000000000..0d9fbfb2f --- /dev/null +++ b/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetails.tsx @@ -0,0 +1,25 @@ +import { + SkeletonDetailsContainer, + SkeletonHero, + SkeletonContent, + SkeletonLine, +} from "@/components/SkeletonMovieDetails/SkeletonMovieDetailsStyle"; + +const SkeletonMovieDetails: React.FC = () => { + return ( + + + + {" "} + + {" "} + {" "} + + + + + + ); +}; + +export default SkeletonMovieDetails; diff --git a/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetailsStyle.ts b/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetailsStyle.ts new file mode 100644 index 000000000..ce4b7feac --- /dev/null +++ b/jobs/Frontend/src/components/SkeletonMovieDetails/SkeletonMovieDetailsStyle.ts @@ -0,0 +1,58 @@ +import styled, { keyframes } from "styled-components"; + +const shimmer = keyframes` + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +`; + +export const SkeletonDetailsContainer = styled.div` + display: flex; + flex-direction: column; + min-height: 100vh; +`; + +export const SkeletonHero = styled.div` + width: 100%; + height: 60vh; + background: #444; + background-image: linear-gradient(90deg, #444 0px, #555 40px, #444 80px); + background-size: 200px 100%; + animation: ${shimmer} 1.5s infinite linear; + + @media (min-width: 768px) { + height: 70vh; + } +`; + +export const SkeletonContent = styled.div` + flex: 1; + max-width: 800px; + padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(2)}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; + + @media (min-width: 768px) { + padding: ${({ theme }) => theme.spacing(6)} + ${({ theme }) => theme.spacing(3)}; + } +`; + +interface SkeletonLineProps { + height?: string; + width?: string; +} + +export const SkeletonLine = styled.div` + height: ${(props) => (props.height ? props.height : "12px")}; + width: ${(props) => (props.width ? props.width : "100%")}; + background: #444; + border-radius: 4px; + background-image: linear-gradient(90deg, #444 0px, #555 40px, #444 80px); + background-size: 200px 100%; + animation: ${shimmer} 1.5s infinite linear; +`; diff --git a/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/Skeleton.tsx b/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/Skeleton.tsx new file mode 100644 index 000000000..b4ce97401 --- /dev/null +++ b/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/Skeleton.tsx @@ -0,0 +1,18 @@ +import { + SkeletonContainer, + SkeletonPoster, + SkeletonSubInfo, + SkeletonTitle, +} from "@/components/SkeletonsGrid/Skeleton/SkeletonStyle"; + +const Skeleton: React.FC = () => { + return ( + + + + + + ); +}; + +export default Skeleton; diff --git a/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/SkeletonStyle.ts b/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/SkeletonStyle.ts new file mode 100644 index 000000000..7f7ccdd5c --- /dev/null +++ b/jobs/Frontend/src/components/SkeletonsGrid/Skeleton/SkeletonStyle.ts @@ -0,0 +1,50 @@ +import styled, { keyframes } from "styled-components"; + +const shimmer = keyframes` + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +`; + +export const SkeletonContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 8px; + padding: ${({ theme }) => theme.spacing(2)}; + overflow: hidden; +`; + +export const SkeletonPoster = styled.div` + width: 100%; + height: 250px; + border-radius: 4px; + background: #444; + background-image: linear-gradient(90deg, #444 0px, #555 40px, #444 80px); + background-size: 200px 100%; + animation: ${shimmer} 1.5s infinite linear; +`; + +export const SkeletonTitle = styled.div` + width: 70%; + height: 12px; + border-radius: 4px; + background: #444; + background-image: linear-gradient(90deg, #444 0px, #555 40px, #444 80px); + background-size: 200px 100%; + animation: ${shimmer} 1.5s infinite linear; +`; + +export const SkeletonSubInfo = styled.div` + width: 40%; + height: 12px; + border-radius: 4px; + background: #444; + background-image: linear-gradient(90deg, #444 0px, #555 40px, #444 80px); + background-size: 200px 100%; + animation: ${shimmer} 1.5s infinite linear; +`; diff --git a/jobs/Frontend/src/components/SkeletonsGrid/SkeletonsGrid.tsx b/jobs/Frontend/src/components/SkeletonsGrid/SkeletonsGrid.tsx new file mode 100644 index 000000000..85d2f24fd --- /dev/null +++ b/jobs/Frontend/src/components/SkeletonsGrid/SkeletonsGrid.tsx @@ -0,0 +1,17 @@ +import Skeleton from "@/components/SkeletonsGrid/Skeleton/Skeleton"; + +interface SkeletonsGridProps { + amount?: number; +} + +const SkeletonsGrid: React.FC = ({ amount = 20 }) => { + return ( + <> + {Array.from({ length: amount }).map((_, i) => ( + + ))} + + ); +}; + +export default SkeletonsGrid; diff --git a/jobs/Frontend/src/hooks/__tests__/useDebounce.test.ts b/jobs/Frontend/src/hooks/__tests__/useDebounce.test.ts new file mode 100644 index 000000000..466246ce6 --- /dev/null +++ b/jobs/Frontend/src/hooks/__tests__/useDebounce.test.ts @@ -0,0 +1,17 @@ +import { renderHook } from "@testing-library/react"; +import useDebounce from "../useDebounce"; + +test("should use counter", () => { + jest.useFakeTimers(); + const mockCallback = jest.fn(); + + const { + result: { current }, + } = renderHook(() => useDebounce(mockCallback, 1000)); + + current(); + jest.advanceTimersToNextTimer(); + + expect(mockCallback).toHaveBeenCalledTimes(1); + jest.useRealTimers(); +}); diff --git a/jobs/Frontend/src/hooks/useDebounce.ts b/jobs/Frontend/src/hooks/useDebounce.ts new file mode 100644 index 000000000..8eabb766e --- /dev/null +++ b/jobs/Frontend/src/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useRef } from "react"; + +const useDebounce = ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callback: Function, + ms: number = 0 +) => { + const timeoutId = useRef(null); + return (...args: ArgsType) => { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + + timeoutId.current = setTimeout(() => { + callback(...args); + }, ms); + }; +}; + +export default useDebounce; diff --git a/jobs/Frontend/src/hooks/useInfiniteScroll.ts b/jobs/Frontend/src/hooks/useInfiniteScroll.ts new file mode 100644 index 000000000..8ad095c21 --- /dev/null +++ b/jobs/Frontend/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import useDebounce from "@/hooks/useDebounce"; + +const useInfiniteScroll = (fetchData: () => void) => { + const handleScroll = () => { + if ( + document.body.scrollHeight - 300 < + window.scrollY + window.innerHeight + ) { + fetchData(); + } + }; + + const debounceScroll = useDebounce(handleScroll, 500); + + useEffect(() => { + window.addEventListener("scroll", debounceScroll); + + return () => { + window.removeEventListener("scroll", debounceScroll); + }; + }, [debounceScroll]); +}; + +export default useInfiniteScroll; diff --git a/jobs/Frontend/src/layouts/Main.tsx b/jobs/Frontend/src/layouts/Main.tsx new file mode 100644 index 000000000..a36f54d78 --- /dev/null +++ b/jobs/Frontend/src/layouts/Main.tsx @@ -0,0 +1,19 @@ +import Footer from "@/components/Footer/Footer"; +import Header from "@/components/Header/Header"; +import { AppContainer } from "@/layouts/MainStyle"; + +interface MainProps { + children: React.ReactNode; +} + +const Main: React.FC = ({ children }) => { + return ( + <> +
+ {children} +