├── .dockerignore ├── src ├── App.scss ├── assets │ ├── lazy_loader.gif │ └── cinema-logo.svg ├── components │ ├── main │ │ ├── Main.scss │ │ └── Main.js │ ├── content │ │ ├── rating │ │ │ ├── Rating.scss │ │ │ └── Rating.js │ │ ├── paginate │ │ │ ├── Paginate.scss │ │ │ └── Paginate.js │ │ ├── search-result │ │ │ ├── SearchResult.scss │ │ │ └── SearchResult.js │ │ ├── main-content │ │ │ ├── MainContent.scss │ │ │ └── MainContent.js │ │ ├── details │ │ │ ├── reviews │ │ │ │ ├── Reviews.scss │ │ │ │ └── Reviews.js │ │ │ ├── tabs │ │ │ │ ├── Tabs.scss │ │ │ │ ├── Tab.js │ │ │ │ └── Tabs.js │ │ │ ├── crew │ │ │ │ ├── Crew.scss │ │ │ │ └── Crew.js │ │ │ ├── media │ │ │ │ ├── Media.scss │ │ │ │ └── Media.js │ │ │ ├── overview │ │ │ │ ├── Overview.scss │ │ │ │ └── Overview.js │ │ │ ├── Details.js │ │ │ └── Details.scss │ │ ├── grid │ │ │ ├── Grid.js │ │ │ └── Grid.scss │ │ └── slide-show │ │ │ ├── Slideshow.scss │ │ │ └── Slideshow.js │ ├── spinner │ │ ├── Spinner.js │ │ ├── Spinner.test.js │ │ └── Spinner.scss │ ├── error │ │ ├── ErrorPage.scss │ │ ├── ErrorPage.js │ │ └── ErrorBoundary.js │ ├── lazy-image │ │ └── LazyImage.js │ └── header │ │ ├── Header.scss │ │ └── Header.js ├── index.scss ├── setupTests.js ├── redux │ ├── actions │ │ ├── routes.js │ │ ├── errors.js │ │ └── movies.js │ ├── reducers │ │ ├── index.js │ │ ├── errorReducer.js │ │ ├── routesReducer.js │ │ └── movieReducer.js │ ├── store.js │ └── types.js ├── routes.js ├── index.js ├── App.js ├── services │ └── movies.service.js └── logo.svg ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .prettierrc.json ├── infrastructure ├── version.tf ├── outputs.tf ├── main.tf ├── variables.tf ├── s3.tf └── cloudfront.tf ├── .editorconfig ├── .gitignore ├── Dockerfile ├── README.md ├── .eslintrc.json ├── package.json └── .circleci └── config.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uzochukwueddie/react-cinema-app/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uzochukwueddie/react-cinema-app/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uzochukwueddie/react-cinema-app/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/assets/lazy_loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uzochukwueddie/react-cinema-app/HEAD/src/assets/lazy_loader.gif -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /src/components/main/Main.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | text-align: center; 3 | height: 100vh; 4 | background-color: #020e18; 5 | overflow-y: scroll; 6 | } 7 | -------------------------------------------------------------------------------- /infrastructure/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.2.0" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = "~> 4.0" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /infrastructure/outputs.tf: -------------------------------------------------------------------------------- 1 | output "cinema_app_bucket_name" { 2 | value = aws_s3_bucket.cinema_app_s3_bucket.id 3 | } 4 | 5 | output "cloudfront_distribution_id" { 6 | value = aws_cloudfront_distribution.s3_distribution.id 7 | } 8 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); 2 | 3 | body { 4 | margin: 0; 5 | font-family: "Roboto Mono", sans-serif; 6 | background-color: #020e18; 7 | cursor: pointer; 8 | } 9 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/components/content/rating/Rating.scss: -------------------------------------------------------------------------------- 1 | .star-rating { 2 | display: flex; 3 | } 4 | 5 | .back-stars { 6 | display: flex; 7 | color: #bdbdbd; 8 | position: relative; 9 | } 10 | 11 | .front-stars { 12 | display: flex; 13 | color: #ffbc0b; 14 | position: absolute; 15 | overflow: hidden; 16 | top: 0; 17 | } 18 | -------------------------------------------------------------------------------- /src/redux/actions/routes.js: -------------------------------------------------------------------------------- 1 | import { APP_ROUTES, PATH_URL } from '../types'; 2 | 3 | export const appRoutes = (routes) => async (dispatch) => { 4 | dispatch({ type: APP_ROUTES, payload: routes }); 5 | }; 6 | 7 | export const pathURL = (path, url) => async (dispatch) => { 8 | dispatch({ type: PATH_URL, payload: { path, url } }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/spinner/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Spinner.scss'; 4 | 5 | const Spinner = () => { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 | ); 13 | }; 14 | 15 | export default Spinner; 16 | -------------------------------------------------------------------------------- /src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import errorReducer from './errorReducer'; 4 | import movieReducer from './movieReducer'; 5 | import routesReducer from './routesReducer'; 6 | 7 | const rootReducers = combineReducers({ 8 | errors: errorReducer, 9 | movies: movieReducer, 10 | routes: routesReducer 11 | }); 12 | 13 | export default rootReducers; 14 | -------------------------------------------------------------------------------- /src/redux/actions/errors.js: -------------------------------------------------------------------------------- 1 | import { SET_ERROR } from '../types'; 2 | 3 | export const setError = (errorMsg) => async (dispatch) => { 4 | if (errorMsg) { 5 | const payload = { 6 | message: errorMsg.message, 7 | statusCode: errorMsg.statusCode 8 | }; 9 | dispatch({ type: SET_ERROR, payload }); 10 | } else { 11 | dispatch({ type: SET_ERROR, payload: { message: '', statusCode: null } }); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import { composeWithDevTools } from '@redux-devtools/extension'; 3 | import thunk from 'redux-thunk'; 4 | 5 | import rootReducers from './reducers'; 6 | 7 | const initialState = {}; 8 | const middleware = [thunk]; 9 | 10 | export const store = createStore(rootReducers, initialState, composeWithDevTools(applyMiddleware(...middleware))); 11 | 12 | export default store; 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | # EditorConfig helps developers define and maintain consistent 3 | # coding styles between different editors and IDEs 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | max_line_length = 200 15 | tab_width = 4 16 | 17 | [*.md] 18 | max_line_length = off 19 | 20 | -------------------------------------------------------------------------------- /src/redux/reducers/errorReducer.js: -------------------------------------------------------------------------------- 1 | import { SET_ERROR } from '../types'; 2 | 3 | const initialState = { 4 | message: '', 5 | statusCode: null 6 | }; 7 | 8 | export default (state = initialState, action) => { 9 | switch (action.type) { 10 | case SET_ERROR: 11 | return { 12 | ...state, 13 | message: action.payload.message, 14 | statusCode: action.payload.statusCode 15 | }; 16 | default: 17 | return state; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | -------------------------------------------------------------------------------- /src/components/content/paginate/Paginate.scss: -------------------------------------------------------------------------------- 1 | .paginate-button { 2 | background-color: #dd003f; 3 | padding: 8px 15px; 4 | text-transform: uppercase; 5 | color: #ffffff; 6 | border: none; 7 | border-radius: 30px; 8 | margin: 0 15px; 9 | outline: none !important; 10 | } 11 | 12 | .disable { 13 | cursor: none; 14 | pointer-events: none; 15 | background: rgba(255, 255, 255, 0.5); 16 | } 17 | 18 | .pageCount { 19 | color: #ffffff; 20 | margin: 0 5px; 21 | padding: 10px 5px; 22 | } 23 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRoutes } from 'react-router-dom'; 3 | import Details from './components/content/details/Details'; 4 | import Main from './components/main/Main'; 5 | 6 | export const AppRoutes = (props) => { 7 | const elements = useRoutes([ 8 | { 9 | path: '/', 10 | element:
11 | }, 12 | { 13 | path: '/:id/:name/details', 14 | element:
15 | } 16 | ]); 17 | 18 | return elements; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/content/search-result/SearchResult.scss: -------------------------------------------------------------------------------- 1 | .grid-search-title { 2 | display: grid; 3 | grid-template-areas: "text1 text2"; 4 | grid-template-rows: max-content max-content; 5 | grid-template-columns: max-content; 6 | margin-top: 100px; 7 | text-align: left; 8 | padding-left: 25px; 9 | font-size: 18px; 10 | } 11 | 12 | .grid-text1 { 13 | grid-area: text1; 14 | padding-right: 8px; 15 | color: #9aa9bb; 16 | } 17 | 18 | .grid-text2 { 19 | grid-area: text2; 20 | color: #ffffff; 21 | } 22 | -------------------------------------------------------------------------------- /infrastructure/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "eu-central-1" 3 | } 4 | 5 | terraform { 6 | backend "s3" { 7 | bucket = "app-cinema-tf-state" 8 | key = "app-cinema.tfstate" 9 | region = "eu-central-1" 10 | encrypt = true 11 | } 12 | } 13 | 14 | locals { 15 | prefix = "${var.prefix}-${terraform.workspace}" 16 | common_tags = { 17 | Environment = terraform.workspace 18 | Project = var.project 19 | ManageBy = "Terraform" 20 | Owner = "Uzochukwu Eddie Odozi" 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/redux/types.js: -------------------------------------------------------------------------------- 1 | export const MOVIE_LIST = 'MOVIE_LIST'; 2 | export const SET_ERROR = 'SET_ERROR'; 3 | export const RESPONSE_PAGE = 'RESPONSE_PAGE'; 4 | export const LOAD_MORE_RESULTS = 'LOAD_MORE_RESULTS'; 5 | export const MOVIE_TYPE = 'MOVIE_TYPE'; 6 | export const SEARCH_QUERY = 'SEARCH_QUERY'; 7 | export const SEARCH_RESULT = 'SEARCH_RESULT'; 8 | export const MOVIE_DETAILS = 'MOVIE_DETAILS'; 9 | export const CLEAR_MOVIE_DETAILS = 'CLEAR_MOVIE_DETAILS'; 10 | export const APP_ROUTES = 'APP_ROUTES'; 11 | export const PATH_URL = 'PATH_URL'; 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Pull from a base image 2 | FROM node:16-alpine 3 | 4 | # use app as the working directory 5 | WORKDIR /app 6 | 7 | ARG REACT_APP_API_SECRET 8 | ARG REACT_APP_SENTRY_DSN 9 | 10 | ENV REACT_APP_API_SECRET=$REACT_APP_API_SECRET 11 | ENV REACT_APP_SENTRY_DSN=$REACT_APP_SENTRY_DSN 12 | 13 | # Copy the files from the current directory to app 14 | COPY . /app 15 | 16 | # Install Dependencies 17 | RUN npm install 18 | 19 | # Build production app 20 | RUN npm run build 21 | 22 | # Listen on the specified port 23 | EXPOSE 3000 24 | 25 | # Set node server 26 | ENTRYPOINT npm run start 27 | -------------------------------------------------------------------------------- /src/redux/reducers/routesReducer.js: -------------------------------------------------------------------------------- 1 | import { APP_ROUTES, PATH_URL } from '../types'; 2 | 3 | const initialState = { 4 | routesArray: [], 5 | path: '', 6 | url: '' 7 | }; 8 | 9 | export default (state = initialState, action) => { 10 | switch (action.type) { 11 | case APP_ROUTES: 12 | return { 13 | ...state, 14 | routesArray: action.payload 15 | }; 16 | case PATH_URL: 17 | return { 18 | ...state, 19 | path: action.payload.path, 20 | url: action.payload.url 21 | }; 22 | default: 23 | return state; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/content/main-content/MainContent.scss: -------------------------------------------------------------------------------- 1 | .main-content { 2 | margin-bottom: 50px; 3 | } 4 | 5 | .grid-movie-title { 6 | display: grid; 7 | grid-template-areas: "movieType . paginate"; 8 | grid-template-columns: max-content 1fr max-content; 9 | grid-template-rows: 1fr; 10 | margin-bottom: 30px; 11 | font-size: 18px; 12 | color: #ffffff; 13 | width: inherit; 14 | 15 | .movieType { 16 | grid-area: movieType; 17 | text-align: left; 18 | padding: 8px 25px; 19 | } 20 | 21 | .paginate { 22 | grid-area: paginate; 23 | text-align: right; 24 | padding-right: 25px; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/spinner/Spinner.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | 5 | import Spinner from './Spinner'; 6 | 7 | describe('Spinner', () => { 8 | test('displays spinner', () => { 9 | const { getByTestId } = render(); 10 | const elem = getByTestId('spinner'); 11 | expect(elem).toBeInTheDocument(); 12 | }); 13 | 14 | test('spinner contains 3 elements', () => { 15 | const { getByTestId } = render(); 16 | const elem = getByTestId('spinner'); 17 | expect(elem.children.length).toBe(3); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A react app built with redux and [The Movie Database API](https://developers.themoviedb.org/3/getting-started/introduction). The app is deployed to AWS S3 and distributed with AWS cloudfront. 2 | 3 | ## Tools 4 | 5 | * React 6 | * Redux 7 | * Sass and CSS Grid 8 | * Github 9 | * Docker 10 | * AWS S3 11 | * AWS Cloudfront 12 | * Slack 13 | * Sentry 14 | 15 | ## App Usage Locally 16 | 17 | * Clone the repo 18 | 19 | * Run `npm install or yarn install` 20 | 21 | * Create an account on [https://www.themoviedb.org/](https://www.themoviedb.org/) and obtain an API key. 22 | 23 | * Create a .env file in the root of the project and add 24 | ```js 25 | REACT_APP_API_SECRET=your api key 26 | ``` 27 | -------------------------------------------------------------------------------- /src/components/content/details/reviews/Reviews.scss: -------------------------------------------------------------------------------- 1 | .movie-reviews { 2 | .div-title { 3 | color: #fff; 4 | font-size: 15px; 5 | font-weight: 700; 6 | border-bottom: 1px solid #233a50; 7 | height: 27px; 8 | line-height: 22.5px; 9 | margin: 30px 0 25px 0; 10 | padding-bottom: 30px; 11 | } 12 | 13 | .reviews { 14 | color: #abb7c4; 15 | line-height: 24px; 16 | font-size: 14px; 17 | font-weight: 300; 18 | margin-bottom: 30px; 19 | text-align: justify; 20 | 21 | h3 { 22 | font-size: 18px; 23 | font-weight: 700; 24 | margin-bottom: 20px; 25 | } 26 | } 27 | 28 | p { 29 | color: #abb7c4; 30 | font-size: 16px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["plugin:react/recommended", "standard"], 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 2018, 16 | "sourceType": "module" 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "16.0" 21 | } 22 | }, 23 | "plugins": ["react"], 24 | "rules": { 25 | "semi": [2, "always"], 26 | "space-before-function-paren": [0, { "anonymous": "always", "named": "always" }], 27 | "camelcase": 0, 28 | "no-return-assign": 0 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import * as Sentry from '@sentry/browser'; 5 | import './index.scss'; 6 | import App from './App'; 7 | import store from './redux/store'; 8 | 9 | if (process.env.NODE_ENV === 'production') { 10 | Sentry.init({ 11 | dsn: process.env.REACT_APP_SENTRY_DSN, 12 | beforeBreadcrumb(breadcrumb, hint) { 13 | return breadcrumb.category === 'ui.click' ? null : breadcrumb; 14 | } 15 | }); 16 | } 17 | 18 | const root = ReactDOM.createRoot(document.getElementById('root')); 19 | root.render( 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/error/ErrorPage.scss: -------------------------------------------------------------------------------- 1 | .error-page { 2 | display: grid; 3 | color: #ffffff; 4 | font-size: 100%; 5 | line-height: 1.5; 6 | height: 100%; 7 | 8 | .error-link { 9 | margin: auto; 10 | font-weight: 300; 11 | color: white; 12 | font-size: 1.2rem; 13 | border: 1px solid #efefef; 14 | padding: 0.5em; 15 | border-radius: 3px; 16 | text-align: center; 17 | text-decoration: none !important; 18 | cursor: pointer; 19 | } 20 | 21 | .error-link:hover { 22 | color: #ffffff; 23 | } 24 | 25 | .error-msg { 26 | font-size: 2em; 27 | text-align: center; 28 | font-weight: 100; 29 | margin-bottom: 10rem; 30 | } 31 | 32 | .error-header { 33 | text-align: center; 34 | font-size: 15em; 35 | font-weight: 100; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/content/details/tabs/Tabs.scss: -------------------------------------------------------------------------------- 1 | .tabs { 2 | text-align: left; 3 | } 4 | 5 | .tab-list { 6 | padding-left: 0; 7 | } 8 | 9 | .tab-list-item { 10 | display: inline-block; 11 | list-style: none; 12 | font-size: 18px; 13 | font-weight: 700; 14 | text-align: center; 15 | text-transform: uppercase; 16 | color: #abb7c4; 17 | padding-bottom: 15px; 18 | margin-right: 50px; 19 | } 20 | 21 | .tab-list-active { 22 | color: #dcf836; 23 | border-bottom: 3px solid #dcf836; 24 | } 25 | 26 | @media (max-width: 700px) { 27 | .tab-list { 28 | grid-template-columns: 1fr; 29 | grid-template-rows: 1fr 1fr; 30 | } 31 | 32 | .tab-list-item { 33 | display: block; 34 | text-align: left; 35 | } 36 | 37 | .tab-list-active { 38 | color: #dcf836; 39 | border-bottom: none; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/content/details/crew/Crew.scss: -------------------------------------------------------------------------------- 1 | .cast { 2 | color: #abb7c4; 3 | 4 | .div-title { 5 | color: #fff; 6 | font-size: 15px; 7 | font-weight: 700; 8 | border-bottom: 1px solid #233a50; 9 | height: 27px; 10 | line-height: 22.5px; 11 | margin: 30px 0 25px 0; 12 | padding-bottom: 30px; 13 | } 14 | 15 | table { 16 | background-color: inherit !important; 17 | border-collapse: collapse; 18 | width: 100%; 19 | font-size: 14px; 20 | } 21 | 22 | .head { 23 | color: #fff; 24 | font-size: 15px; 25 | font-weight: 700; 26 | } 27 | 28 | td, th { 29 | text-align: left; 30 | padding: 8px; 31 | } 32 | 33 | td:first-child { 34 | width: 70px; 35 | img { 36 | border-radius: 5px; 37 | margin-left: -5px; 38 | } 39 | } 40 | 41 | td:nth-child(2) { 42 | font-size: 16px; 43 | color: #4280bf; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/content/details/tabs/Tab.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Tab = (props) => { 5 | const { activeTab, label, onClick } = props; 6 | const [className, setClassName] = useState('tab-list-item'); 7 | 8 | useEffect(() => { 9 | if (activeTab === label) { 10 | setClassName((prev) => (prev += ' tab-list-active')); 11 | } else { 12 | setClassName('tab-list-item'); 13 | } 14 | }, [activeTab, label]); 15 | 16 | const onTabClick = () => { 17 | onClick(label); 18 | }; 19 | 20 | return ( 21 | <> 22 |
  • 23 | {label} 24 |
  • 25 | 26 | ); 27 | }; 28 | 29 | Tab.propTypes = { 30 | activeTab: PropTypes.string.isRequired, 31 | label: PropTypes.string.isRequired, 32 | onClick: PropTypes.func.isRequired 33 | }; 34 | 35 | export default Tab; 36 | -------------------------------------------------------------------------------- /src/components/error/ErrorPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import PropTypes from 'prop-types'; 4 | import { connect } from 'react-redux'; 5 | 6 | import './ErrorPage.scss'; 7 | import { setError } from '../../redux/actions/errors'; 8 | 9 | const ErrorPage = ({ clearState, setError }) => { 10 | const navigate = useNavigate(); 11 | 12 | const navigateToHomePage = () => { 13 | setError({ message: '', statusCode: null }); 14 | clearState(); 15 | navigate('/'); 16 | }; 17 | 18 | return ( 19 |
    20 |

    Oops!

    21 |

    Something went wrong.

    22 |
    navigateToHomePage()}> 23 | Go back to home page. 24 |
    25 |
    26 | ); 27 | }; 28 | 29 | ErrorPage.propTypes = { 30 | clearState: PropTypes.func, 31 | setError: PropTypes.func 32 | }; 33 | 34 | export default connect(null, { setError })(ErrorPage); 35 | -------------------------------------------------------------------------------- /src/components/content/details/tabs/Tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './Tabs.scss'; 5 | import Tab from './Tab'; 6 | 7 | const Tabs = (props) => { 8 | const { children } = props; 9 | const [activeTab, setActiveTab] = useState(children[0].props.label); 10 | 11 | const onClickTabItem = (tab) => { 12 | setActiveTab(tab); 13 | }; 14 | 15 | return ( 16 |
    17 |
      18 | {children.map((child) => { 19 | const { label } = child.props; 20 | return ; 21 | })} 22 |
    23 |
    24 | {children.map((child) => { 25 | if (child.props.label !== activeTab) return undefined; 26 | return child.props.children; 27 | })} 28 |
    29 |
    30 | ); 31 | }; 32 | 33 | Tabs.propTypes = { 34 | children: PropTypes.array.isRequired 35 | }; 36 | 37 | export default Tabs; 38 | -------------------------------------------------------------------------------- /src/components/spinner/Spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin: 100px auto 0; 3 | width: 100px; 4 | text-align: center; 5 | position: absolute; 6 | left: 50%; 7 | top: 50%; 8 | transform: translate(-50%, -50%); 9 | } 10 | 11 | .spinner > div { 12 | width: 18px; 13 | height: 18px; 14 | background-color: #ffffff; 15 | border-radius: 100%; 16 | display: inline-block; 17 | animation: bounce 1.4s infinite ease-in-out both; 18 | -webkit-animation: bounce 1.4s infinite ease-in-out both; 19 | } 20 | 21 | .spinner .bounce1 { 22 | animation-delay: -0.32s; 23 | -webkit-animation-delay: -0.32s; 24 | } 25 | 26 | .spinner .bounce2 { 27 | animation-delay: -0.16s; 28 | -webkit-animation-delay: -0.16s; 29 | } 30 | 31 | @keyframes bounce { 32 | 0%, 33 | 80%, 34 | 100% { 35 | -webkit-transform: scale(0); 36 | transform: scale(0); 37 | } 38 | 40% { 39 | -webkit-transform: scale(1); 40 | transform: scale(1); 41 | } 42 | } 43 | 44 | @-webkit-keyframes bounce { 45 | 0%, 46 | 80%, 47 | 100% { 48 | -webkit-transform: scale(0); 49 | } 50 | 40% { 51 | -webkit-transform: scale(1); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/content/details/reviews/Reviews.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | import './Reviews.scss'; 7 | 8 | const Reviews = (props) => { 9 | const { movie } = props; 10 | const [reviews] = useState(movie[4]); 11 | 12 | return ( 13 | <> 14 |
    15 |
    Reviews {reviews.results.length > 0 ? reviews.results.length : ''}
    16 | {reviews.results.length ? ( 17 | reviews.results.map((data) => ( 18 |
    19 |

    {data.author}

    20 |
    {data.content}
    21 |
    22 | )) 23 | ) : ( 24 |

    No reviews to show

    25 | )} 26 |
    27 | 28 | ); 29 | }; 30 | 31 | Reviews.propTypes = { 32 | movie: PropTypes.array 33 | }; 34 | 35 | const mapStateToProps = (state) => ({ 36 | movie: state.movies.movie 37 | }); 38 | 39 | export default connect(mapStateToProps, {})(Reviews); 40 | -------------------------------------------------------------------------------- /src/components/content/paginate/Paginate.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './Paginate.scss'; 5 | 6 | const Paginate = (props) => { 7 | const { currentPage, totalPages, paginate } = props; 8 | const [page, setPage] = useState(); 9 | const [totalPageNumber, setTotalPageNumber] = useState(); 10 | 11 | useEffect(() => { 12 | setPage(currentPage); 13 | setTotalPageNumber(totalPages); 14 | }, [currentPage, totalPages]); 15 | 16 | return ( 17 | <> 18 | 19 | {page} - {totalPageNumber} 20 | 21 | 24 | 27 | 28 | ); 29 | }; 30 | 31 | Paginate.propTypes = { 32 | currentPage: PropTypes.number.isRequired, 33 | totalPages: PropTypes.number.isRequired, 34 | paginate: PropTypes.func.isRequired 35 | }; 36 | 37 | export default Paginate; 38 | -------------------------------------------------------------------------------- /infrastructure/variables.tf: -------------------------------------------------------------------------------- 1 | variable "prefix" { 2 | default = "cinema-react-eddie" 3 | } 4 | 5 | variable "project" { 6 | default = "cinema-react-app" 7 | } 8 | 9 | variable "custom_error_response" { 10 | type = list(object({ 11 | error_caching_min_ttl = number 12 | error_code = number 13 | response_code = number 14 | response_page_path = string 15 | })) 16 | description = "List of one or more custom error response element maps" 17 | default = [ 18 | { 19 | error_caching_min_ttl = 10 20 | error_code = 400 21 | response_code = 200 22 | response_page_path = "/index.html" 23 | }, 24 | { 25 | error_caching_min_ttl = 10 26 | error_code = 403 27 | response_code = 200 28 | response_page_path = "/index.html" 29 | }, 30 | { 31 | error_caching_min_ttl = 10 32 | error_code = 404 33 | response_code = 200 34 | response_page_path = "/index.html" 35 | }, 36 | { 37 | error_caching_min_ttl = 10 38 | error_code = 405 39 | response_code = 200 40 | response_page_path = "/index.html" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/components/error/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as Sentry from '@sentry/browser'; 4 | 5 | import ErrorPage from './ErrorPage'; 6 | 7 | class ErrorBoundary extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { error: null, errorInfo: null, eventId: null }; 11 | this.clearState = this.clearState.bind(this); 12 | } 13 | 14 | componentDidCatch(error, errorInfo) { 15 | this.setState({ error, errorInfo }); 16 | if (process.env.NODE_ENV === 'production') { 17 | Sentry.withScope((scope) => { 18 | scope.setTag('Custom-Tag', 'ErrorBoundary'); 19 | scope.setLevel('Error'); 20 | scope.setExtras(errorInfo); 21 | const eventId = Sentry.captureException(error); 22 | this.setState({ eventId }); 23 | }); 24 | } 25 | } 26 | 27 | clearState() { 28 | this.setState({ error: null, errorInfo: null, eventId: null }); 29 | } 30 | 31 | render() { 32 | if (this.state.error) { 33 | return ; 34 | } 35 | return this.props.children; 36 | } 37 | } 38 | 39 | ErrorBoundary.propTypes = { 40 | children: PropTypes.any 41 | }; 42 | 43 | export default ErrorBoundary; 44 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import './App.scss'; 7 | import Header from './components/header/Header'; 8 | import Main from './components/main/Main'; 9 | import Details from './components/content/details/Details'; 10 | import ErrorBoundary from './components/error/ErrorBoundary'; 11 | import { appRoutes } from './redux/actions/routes'; 12 | import { AppRoutes } from './routes'; 13 | 14 | const App = (props) => { 15 | const { appRoutes } = props; 16 | const routesArray = [ 17 | { 18 | id: 1, 19 | path: '/', 20 | component: Main 21 | }, 22 | { 23 | id: 2, 24 | path: '/:id/:name/details', 25 | component: Details 26 | } 27 | ]; 28 | 29 | useEffect(() => { 30 | appRoutes(routesArray); 31 | }, [routesArray, appRoutes]); 32 | 33 | return ( 34 |
    35 | 36 | 37 |
    38 | 39 | 40 | 41 |
    42 | ); 43 | }; 44 | 45 | App.propTypes = { 46 | appRoutes: PropTypes.func 47 | }; 48 | 49 | export default connect(null, { appRoutes })(App); 50 | -------------------------------------------------------------------------------- /src/components/content/details/media/Media.scss: -------------------------------------------------------------------------------- 1 | .media { 2 | display: grid; 3 | grid-template-areas: 4 | "title" 5 | "images" 6 | "videos"; 7 | grid-template-columns: 1fr; 8 | grid-template-rows: max-content; 9 | } 10 | 11 | .media-title { 12 | grid-area: title; 13 | color: #fff; 14 | font-size: 15px; 15 | font-weight: 700; 16 | border-bottom: 1px solid #233a50; 17 | height: 27px; 18 | line-height: 22.5px; 19 | margin: 30px 0 25px 0; 20 | padding-bottom: 30px; 21 | } 22 | 23 | .media-images { 24 | grid-area: images; 25 | display: grid; 26 | grid-template-columns: repeat(auto-fill, minmax(330px, auto)); 27 | row-gap: 1rem; 28 | column-gap: 0.5rem; 29 | 30 | .image-cell { 31 | width: 330px; 32 | height: 550px; 33 | transition: all 500ms; 34 | background-size: cover; 35 | background-position: center; 36 | background-repeat: no-repeat; 37 | } 38 | } 39 | 40 | .image-cell:hover { 41 | box-shadow: rgba(2, 8, 20, 0.1) 0px 0.35em 1.175em, rgba(2, 8, 20, 0.08) 0px 0.175em 0.5em; 42 | transform: translateY(-3px) scale(1.1); 43 | } 44 | 45 | .media-videos { 46 | grid-area: videos; 47 | display: grid; 48 | grid-template-columns: repeat(auto-fill, minmax(330px, auto)); 49 | row-gap: 1rem; 50 | column-gap: 1rem; 51 | 52 | .video { 53 | height: 313px; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 24 | 25 | 31 | Cinema App - Dev 32 | 33 | 34 | 35 |
    36 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/content/details/crew/Crew.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | import './Crew.scss'; 7 | import { IMAGE_URL } from '../../../../services/movies.service'; 8 | 9 | const Crew = (props) => { 10 | const { movie } = props; 11 | const [credits] = useState(movie[1]); 12 | 13 | return ( 14 | <> 15 |
    16 |
    Crew
    17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {credits.crew.map((data) => ( 27 | 28 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | ))} 38 |
    DepartmentJob
    30 | 31 | {data.name}{data.department}{data.job}
    39 |
    40 | 41 | ); 42 | }; 43 | 44 | Crew.propTypes = { 45 | movie: PropTypes.array 46 | }; 47 | 48 | const mapStateToProps = (state) => ({ 49 | movie: state.movies.movie 50 | }); 51 | 52 | export default connect(mapStateToProps, {})(Crew); 53 | -------------------------------------------------------------------------------- /src/redux/reducers/movieReducer.js: -------------------------------------------------------------------------------- 1 | import { MOVIE_LIST, RESPONSE_PAGE, LOAD_MORE_RESULTS, MOVIE_TYPE, SEARCH_QUERY, SEARCH_RESULT, MOVIE_DETAILS, CLEAR_MOVIE_DETAILS } from '../types'; 2 | 3 | const initialState = { 4 | list: [], 5 | page: 1, 6 | totalPages: 0, 7 | movieType: 'now_playing', 8 | searchQuery: '', 9 | searchResult: [], 10 | movie: [] 11 | }; 12 | 13 | export default (state = initialState, action) => { 14 | switch (action.type) { 15 | case MOVIE_LIST: 16 | return { 17 | ...state, 18 | list: action.payload 19 | }; 20 | case RESPONSE_PAGE: 21 | return { 22 | ...state, 23 | page: action.payload.page, 24 | totalPages: action.payload.totalPages 25 | }; 26 | case LOAD_MORE_RESULTS: 27 | return { 28 | ...state, 29 | list: [...state.list, ...action.payload.list], 30 | page: action.payload.page, 31 | totalPages: action.payload.totalPages 32 | }; 33 | case MOVIE_TYPE: 34 | return { 35 | ...state, 36 | movieType: action.payload 37 | }; 38 | case SEARCH_RESULT: 39 | return { 40 | ...state, 41 | searchResult: action.payload 42 | }; 43 | case SEARCH_QUERY: 44 | return { 45 | ...state, 46 | searchQuery: action.payload 47 | }; 48 | case MOVIE_DETAILS: 49 | return { 50 | ...state, 51 | movie: action.payload 52 | }; 53 | case CLEAR_MOVIE_DETAILS: 54 | return { 55 | ...state, 56 | movie: [] 57 | }; 58 | default: 59 | return state; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/assets/cinema-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/content/rating/Rating.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, Fragment, useRef } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './Rating.scss'; 5 | 6 | const Rating = ({ rating, totalStars, className }) => { 7 | const [numberOfStars, setNumberOfStars] = useState(); 8 | const ratingRef = useRef(); 9 | 10 | useEffect(() => { 11 | setNumberOfStars([...Array(totalStars).keys()].map((i) => i + 1)); 12 | let percentage; 13 | if (rating <= 5) { 14 | percentage = (rating / 5) * 100; 15 | } else { 16 | percentage = (rating / 10) * 100; 17 | } 18 | const startPercentage = `${Math.floor(percentage)}%`; 19 | ratingRef.current.style.width = startPercentage; 20 | }, [rating, totalStars]); 21 | 22 | return ( 23 |
    24 |
    25 | {numberOfStars && 26 | numberOfStars.map((i) => ( 27 | 28 | 29 | 30 | ))} 31 | 32 |
    33 | {numberOfStars && 34 | numberOfStars.map((i) => ( 35 | 36 | 37 | 38 | ))} 39 |
    40 |
    41 |
    42 | ); 43 | }; 44 | 45 | Rating.propTypes = { 46 | rating: PropTypes.number.isRequired, 47 | totalStars: PropTypes.number.isRequired, 48 | className: PropTypes.string 49 | }; 50 | 51 | export default Rating; 52 | -------------------------------------------------------------------------------- /src/services/movies.service.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const REQUEST_URL = 'https://api.themoviedb.org/3'; 4 | export const IMAGE_URL = 'https://image.tmdb.org/t/p/original'; 5 | const API_KEY = process.env.REACT_APP_API_SECRET; 6 | 7 | export const MOVIE_API_URL = async (type, page) => { 8 | const response = await axios.get(`${REQUEST_URL}/movie/${type}?api_key=${API_KEY}&language=en-US&page=${page}`); 9 | return response; 10 | }; 11 | 12 | export const SEARCH_API_URL = async (query) => { 13 | const response = await axios.get(`${REQUEST_URL}/search/movie?api_key=${API_KEY}&language=en-US&query=${query}`); 14 | return response; 15 | }; 16 | 17 | export const MOVIE_DETAILS_URL = async (id) => { 18 | const response = await axios.get(`${REQUEST_URL}/movie/${id}?api_key=${API_KEY}&language=en-US`); 19 | return response; 20 | }; 21 | 22 | export const MOVIE_CREDITS_URL = async (id) => { 23 | const response = await axios.get(`${REQUEST_URL}/movie/${id}/credits?api_key=${API_KEY}&language=en-US`); 24 | return response; 25 | }; 26 | 27 | export const MOVIE_IMAGES_URL = async (id) => { 28 | const response = await axios.get(`${REQUEST_URL}/movie/${id}/images?api_key=${API_KEY}&language=en-US&include_image_language=en`); 29 | return response; 30 | }; 31 | 32 | export const MOVIE_VIDEOS_URL = async (id) => { 33 | const response = await axios.get(`${REQUEST_URL}/movie/${id}/videos?api_key=${API_KEY}&language=en-US`); 34 | return response; 35 | }; 36 | 37 | export const MOVIE_REVIEWS_URL = async (id, page = 1) => { 38 | const response = await axios.get(`${REQUEST_URL}/movie/${id}/reviews?api_key=${API_KEY}&language=en-US&page=${page}`); 39 | return response; 40 | }; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cinema", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@redux-devtools/extension": "^3.2.3", 7 | "@sentry/browser": "^7.13.0", 8 | "@testing-library/jest-dom": "^5.16.5", 9 | "@testing-library/react": "^13.4.0", 10 | "@testing-library/user-event": "^14.4.3", 11 | "axios": "^0.27.2", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "react-redux": "^8.0.4", 15 | "react-router-dom": "^6.4.1", 16 | "react-scripts": "^5.0.1", 17 | "redux": "^4.2.0", 18 | "redux-thunk": "^2.4.1", 19 | "sass": "^1.55.0", 20 | "uuid": "^9.0.0" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "lint": "eslint 'src/**/*.js*'", 26 | "prettier:check": "prettier --check 'src/**/*.{js,jsx,json}'", 27 | "prettier:write": "prettier --write 'src/**/*.{js,jsx,json}'", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": "react-app" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "eslint": "^6.8.0", 48 | "eslint-config-standard": "^14.1.1", 49 | "eslint-plugin-import": "^2.20.2", 50 | "eslint-plugin-node": "^11.1.0", 51 | "eslint-plugin-promise": "^4.2.1", 52 | "eslint-plugin-react": "^7.19.0", 53 | "eslint-plugin-standard": "^4.0.1", 54 | "prettier": "^2.0.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/lazy-image/LazyImage.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import placeHolder from '../../assets/lazy_loader.gif'; 5 | 6 | const LazyImage = (props) => { 7 | const { src, alt, children, className } = props; 8 | const [imageSrc, setImageSrc] = useState(placeHolder); 9 | const [imageRef, setImageRef] = useState(); 10 | 11 | useEffect(() => { 12 | let observer; 13 | let didCancel = false; 14 | 15 | if (imageRef && imageSrc !== src) { 16 | if (IntersectionObserver) { 17 | observer = new IntersectionObserver( 18 | (entries) => { 19 | entries.forEach((entry) => { 20 | if (!didCancel && (entry.intersectionRatio > 0 || entry.isIntersecting)) { 21 | setImageSrc(src); 22 | observer.unobserve(imageRef); 23 | } 24 | }); 25 | }, 26 | { 27 | threshold: 0.01, 28 | rootMargin: '75%' 29 | } 30 | ); 31 | observer.observe(imageRef); 32 | } else { 33 | setImageSrc(src); 34 | } 35 | } 36 | 37 | return () => { 38 | didCancel = true; 39 | if (observer && observer.unobserve) { 40 | observer.unobserve(imageRef); 41 | } 42 | }; 43 | }, [src, imageSrc, imageRef]); 44 | 45 | return ( 46 | <> 47 |
    48 | {children} 49 |
    50 | 51 | ); 52 | }; 53 | 54 | LazyImage.propTypes = { 55 | src: PropTypes.string, 56 | alt: PropTypes.string, 57 | children: PropTypes.any, 58 | className: PropTypes.any 59 | }; 60 | 61 | export default LazyImage; 62 | -------------------------------------------------------------------------------- /infrastructure/s3.tf: -------------------------------------------------------------------------------- 1 | ########################### 2 | # S3 RESOURCES 3 | ########################### 4 | 5 | resource "aws_s3_bucket" "cinema_app_s3_bucket" { 6 | bucket = "${local.prefix}-app" 7 | force_destroy = true 8 | 9 | tags = local.common_tags 10 | } 11 | 12 | resource "aws_s3_bucket_acl" "cinema_app_bucket_acl" { 13 | bucket = aws_s3_bucket.cinema_app_s3_bucket.id 14 | acl = "private" 15 | } 16 | 17 | resource "aws_s3_bucket_public_access_block" "public_block" { 18 | bucket = aws_s3_bucket.cinema_app_s3_bucket.id 19 | 20 | block_public_acls = true 21 | block_public_policy = true 22 | restrict_public_buckets = true 23 | ignore_public_acls = true 24 | } 25 | 26 | resource "aws_s3_bucket_versioning" "cinema_app_bucket_versioning" { 27 | bucket = aws_s3_bucket.cinema_app_s3_bucket.id 28 | versioning_configuration { 29 | status = "Enabled" 30 | } 31 | } 32 | 33 | resource "aws_s3_bucket_policy" "cinema_app_bucket_policy" { 34 | bucket = aws_s3_bucket.cinema_app_s3_bucket.id 35 | policy = data.aws_iam_policy_document.cinema_app_bucket_policy_document.json 36 | } 37 | 38 | resource "aws_s3_bucket_website_configuration" "cinema_app_bucket_website" { 39 | bucket = aws_s3_bucket.cinema_app_s3_bucket.id 40 | 41 | index_document { 42 | suffix = "index.html" 43 | } 44 | 45 | error_document { 46 | key = "index.html" 47 | } 48 | } 49 | 50 | data "aws_iam_policy_document" "cinema_app_bucket_policy_document" { 51 | statement { 52 | actions = ["s3:GetObject"] 53 | 54 | resources = [ 55 | aws_s3_bucket.cinema_app_s3_bucket.arn, 56 | "${aws_s3_bucket.cinema_app_s3_bucket.arn}/*" 57 | ] 58 | 59 | principals { 60 | type = "AWS" 61 | identifiers = [aws_cloudfront_origin_access_identity.cinema_app_origin_access.iam_arn] 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/content/details/media/Media.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import './Media.scss'; 6 | import { IMAGE_URL } from '../../../../services/movies.service'; 7 | 8 | const Media = (props) => { 9 | const { movie } = props; 10 | const [media] = useState(movie[2]); 11 | const [videos] = useState(movie[3]); 12 | 13 | return ( 14 | <> 15 |
    16 |
    17 |
    Watch Trailer
    18 |
    19 | {videos.results.map((data) => ( 20 |
    21 |