diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0ebb282c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package.lock.json +yarn.lock +yarn-error.log diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..36222370 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,13 @@ +const presets = [ + [ + "@babel/preset-env", + { + useBuiltIns: "usage" + } + ], + "@babel/preset-react" +]; + +const plugins = ["@babel/plugin-proposal-class-properties"]; + +module.exports = { presets, plugins }; diff --git a/package.json b/package.json new file mode 100644 index 00000000..e6db802a --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "reactjs-card-challenge", + "version": "1.0.0", + "description": "A responsive card based design, to be implemented by React & Redux", + "main": "index.js", + "scripts": { + "dev": "webpack-dev-server --config webpack.dev.js", + "build": "webpack --config webpack.prod.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Am-Ta/reactjs-card-challenge.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/Am-Ta/reactjs-card-challenge/issues" + }, + "homepage": "https://github.com/Am-Ta/reactjs-card-challenge#readme", + "devDependencies": { + "@babel/core": "^7.6.4", + "@babel/plugin-proposal-class-properties": "^7.5.5", + "@babel/preset-env": "^7.6.3", + "@babel/preset-react": "^7.6.3", + "babel-loader": "^8.0.6", + "clean-webpack-plugin": "^3.0.0", + "css-loader": "^3.2.0", + "file-loader": "^4.2.0", + "html-webpack-plugin": "^3.2.0", + "mini-css-extract-plugin": "^0.8.0", + "node-sass": "^4.13.0", + "sass-loader": "^8.0.0", + "style-loader": "^1.0.0", + "webpack": "^4.41.2", + "webpack-cli": "^3.3.9", + "webpack-dev-server": "^3.9.0" + }, + "dependencies": { + "@babel/polyfill": "^7.6.0", + "prop-types": "^15.7.2", + "react": "^16.11.0", + "react-dom": "^16.11.0", + "react-redux": "^7.1.1", + "redux": "^4.0.4", + "redux-devtools-extension": "^2.13.8", + "redux-thunk": "^2.3.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..5f91cf6e --- /dev/null +++ b/public/index.html @@ -0,0 +1,16 @@ + + + + + + + <%= htmlWebpackPlugin.options.title %> + + + + + +
+ + + \ No newline at end of file diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..affe2c2b --- /dev/null +++ b/src/App.js @@ -0,0 +1,38 @@ +import React, { useEffect } from "react"; +import PropTypes from "prop-types"; + +import BtnRandGen from "./component/BtnRandGen"; +import CardBox from "./component/card/CardBox"; + +import { connect } from "react-redux"; +import { fetchCards } from "./action/CardAction"; + +import "./App.scss"; + +const App = ({ cardRes: { card }, fetchCards }) => { + // Fetch the cards + useEffect(() => { + fetchCards(); + }, []); + + return ( +
+ + {card && } +
+ ); +}; + +App.propTypes = { + cardRes: PropTypes.shape({ card: PropTypes.object }), + fetchCards: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + cardRes: state.cardRes +}); + +export default connect( + mapStateToProps, + { fetchCards } +)(App); diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 00000000..440065cf --- /dev/null +++ b/src/App.scss @@ -0,0 +1,239 @@ +// Simple css reset +* { + margin: 0; + padding: 0; +} + +// Define font +@font-face { + font-family: "JosefinSans"; + src: url("./assets/font/JosefinSans-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: "JosefinSans"; + src: url("./assets/font/JosefinSans-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; +} + +// Fix the from element font +input, +button, +textarea { + font-family: inherit; + font-size: inherit; + outline: none; +} + +// Fix the clear +html { + box-sizing: border-box; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; +} + +*, +*:before, +*:after { + box-sizing: inherit; + -webkit-box-sizing: inherit; + -moz-box-sizing: inherit; +} + +// Variables +$primary-color: #00a4e4; +$success-color: #00c300; +$danger-color: #ec1c24; +$dark-color: #231f20; +$light-color: #d7d7d8; +$danger-color: #ff3d00; +$font-stack: "JosefinSans", Arial, Helvetica, sans-serif; + +// Set the global font and color for whole app +body { + font-family: $font-stack; + color: $dark-color; +} + +// Container +.container { + width: 90%; + min-height: 70vh; + margin: auto; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; +} + +// Btn Component +.btn { + background-color: transparent; + border: none; + text-decoration: none; + cursor: pointer; + padding: 0.5em 1em; + border-radius: 3px; + transition: 0.5s; + &.btn_success { + background-color: $success-color; + color: #fff; + &:hover { + background-color: rgba($success-color, 0.8); + } + } + &.btn_primary { + background-color: $primary-color; + color: #fff; + &:hover { + background-color: rgba($primary-color, 0.8); + } + } + &.btn_dark { + background-color: $dark-color; + color: #fff; + &:hover { + background-color: rgba($dark-color, 0.9); + } + } + &.btn_block { + width: 100%; + } +} + +// Card-box is wrapper for card item and card updateor +.card-box { + width: 200px; +} + +// Card Item Component +.card { + text-align: center; + .card__img-box { + width: 100%; + .card__img { + display: block; + width: 100%; + } + } + &.card_show { + animation: shake 0.5s ease infinite; + -webkit-animation: shake 0.5s ease infinite; + -moz-animation: shake 0.5s ease infinite; + } + + .card__info { + margin: 1em; + .card__title { + padding: 0.5em; + display: flex; + justify-content: space-around; + + &.card_sport { + color: $success-color; + } + &.card_fun { + color: $primary-color; + } + &.card_art { + color: $danger-color; + } + } + } +} + +// Form Component +.form { + .form__item { + margin-bottom: 1em; + .form__label { + color: $success-color; + display: block; + padding: 0.25em 0; + } + .form__input { + width: 100%; + padding: 0.5em; + resize: none; + color: rgba($dark-color, 0.8); + border: 1px solid #aaa; + &:hover, + &:focus { + border-color: $success-color; + } + } + textarea.form__input { + height: 100px; + } + } +} + +// Animation for cards with code equal to 1 +@keyframes shake { + 0% { + transform: translate(1px, 1px) rotate(0deg); + -webkit-transform: translate(1px, 1px) rotate(0deg); + -moz-transform: translate(1px, 1px) rotate(0deg); + } + 10% { + transform: translate(-1px, -2px) rotate(-1deg); + -webkit-transform: translate(-1px, -2px) rotate(-1deg); + -moz-transform: translate(-1px, -2px) rotate(-1deg); + } + 20% { + transform: translate(-3px, 0px) rotate(1deg); + -webkit-transform: translate(-3px, 0px) rotate(1deg); + -moz-transform: translate(-3px, 0px) rotate(1deg); + } + 30% { + transform: translate(3px, 2px) rotate(0deg); + -webkit-transform: translate(3px, 2px) rotate(0deg); + -moz-transform: translate(3px, 2px) rotate(0deg); + } + 40% { + transform: translate(1px, -1px) rotate(1deg); + -webkit-transform: translate(1px, -1px) rotate(1deg); + -moz-transform: translate(1px, -1px) rotate(1deg); + } + 50% { + transform: translate(-1px, 2px) rotate(-1deg); + -webkit-transform: translate(-1px, 2px) rotate(-1deg); + -moz-transform: translate(-1px, 2px) rotate(-1deg); + } + 60% { + transform: translate(-3px, 1px) rotate(0deg); + -webkit-transform: translate(-3px, 1px) rotate(0deg); + -moz-transform: translate(-3px, 1px) rotate(0deg); + } + 70% { + transform: translate(3px, 1px) rotate(-1deg); + -webkit-transform: translate(3px, 1px) rotate(-1deg); + -moz-transform: translate(3px, 1px) rotate(-1deg); + } + 80% { + transform: translate(-1px, -1px) rotate(1deg); + -webkit-transform: translate(-1px, -1px) rotate(1deg); + -moz-transform: translate(-1px, -1px) rotate(1deg); + } + 90% { + transform: translate(1px, 2px) rotate(0deg); + -webkit-transform: translate(1px, 2px) rotate(0deg); + -moz-transform: translate(1px, 2px) rotate(0deg); + } + 100% { + transform: translate(1px, -2px) rotate(-1deg); + -webkit-transform: translate(1px, -2px) rotate(-1deg); + -moz-transform: translate(1px, -2px) rotate(-1deg); + } +} + +// Screen min-width 40em +@media screen and (min-width: 40em) { + .container { + width: 80%; + flex-direction: row; + } +} diff --git a/src/action/CardAction.js b/src/action/CardAction.js new file mode 100644 index 00000000..2773b6ec --- /dev/null +++ b/src/action/CardAction.js @@ -0,0 +1,44 @@ +import { + CARD_ERROR, + FETCH_CARDS, + UPDATE_CARD, + SELECT_CARD, + SET_CURRENT +} from "./types"; + +// Fetch the cards from the pushe api +export const fetchCards = () => async dispatch => { + try { + const res = await fetch("http://static.pushe.co/challenge/json"); + const data = await res.json(); + + dispatch({ + type: FETCH_CARDS, + payload: data.cards + }); + } catch (err) { + dispatch({ + type: CARD_ERROR, + payload: err.response.data + }); + } +}; + +// Select the card by random +export const selectCard = () => { + let randomNum = Math.random(); + return { + type: SELECT_CARD, + payload: randomNum + }; +}; + +export const setCurrent = card => ({ + type: SET_CURRENT, + payload: card +}); + +export const updateCard = (title, description) => ({ + type: UPDATE_CARD, + payload: { title, description } +}); diff --git a/src/action/types.js b/src/action/types.js new file mode 100644 index 00000000..60272fc7 --- /dev/null +++ b/src/action/types.js @@ -0,0 +1,5 @@ +export const FETCH_CARDS = "fetchCards"; +export const CARD_ERROR = "cardError"; +export const SELECT_CARD = "selectCard"; +export const UPDATE_CARD = "updateCard"; +export const SET_CURRENT = "setCurrent"; diff --git a/src/assets/font/JosefinSans-Bold.ttf b/src/assets/font/JosefinSans-Bold.ttf new file mode 100644 index 00000000..f4166a1e Binary files /dev/null and b/src/assets/font/JosefinSans-Bold.ttf differ diff --git a/src/assets/font/JosefinSans-Regular.ttf b/src/assets/font/JosefinSans-Regular.ttf new file mode 100644 index 00000000..292dcb07 Binary files /dev/null and b/src/assets/font/JosefinSans-Regular.ttf differ diff --git a/src/component/BtnRandGen.js b/src/component/BtnRandGen.js new file mode 100644 index 00000000..35c7cbb1 --- /dev/null +++ b/src/component/BtnRandGen.js @@ -0,0 +1,34 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { connect } from "react-redux"; +import { selectCard } from "./../action/CardAction"; + +const BtnRandGen = ({ cardRes: { current }, selectCard }) => { + // Handle the click for generate random card + const handleClick = () => { + !current && selectCard(); + }; + + return ( +
+ +
+ ); +}; + +BtnRandGen.propTypes = { + cardRes: PropTypes.shape({ current: PropTypes.object }), + selectCard: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + cardRes: state.cardRes +}); + +export default connect( + mapStateToProps, + { selectCard } +)(BtnRandGen); diff --git a/src/component/card/CardBox.js b/src/component/card/CardBox.js new file mode 100644 index 00000000..3ecc3347 --- /dev/null +++ b/src/component/card/CardBox.js @@ -0,0 +1,29 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import CardItem from "./CardItem"; +import CardEdit from "./CardEdit"; + +import { connect } from "react-redux"; + +const CardBox = ({ cardRes: { current } }) => { + return ( +
+ {!current && } + {current && } +
+ ); +}; + +CardBox.propTypes = { + cardRes: PropTypes.shape({ current: PropTypes.object }) +}; + +const mapStateToProps = state => ({ + cardRes: state.cardRes +}); + +export default connect( + mapStateToProps, + {} +)(CardBox); diff --git a/src/component/card/CardEdit.js b/src/component/card/CardEdit.js new file mode 100644 index 00000000..a8248e4a --- /dev/null +++ b/src/component/card/CardEdit.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "react-redux"; +import { updateCard } from "../../action/CardAction"; + +const CardEdit = ({ cardRes: { current }, updateCard }) => { + const { title, description } = current; + const [form, setForm] = useState({ title: "", description: "" }); + + // Load the current title and description of card for the form state + useEffect(() => { + setForm({ title, description }); + }, []); + + // Handle the changes on the title and description of the card + const handleChange = e => { + setForm({ ...form, [e.target.name]: e.target.value }); + }; + + // Handle the submit for new card + const handleSubmit = e => { + e.preventDefault(); + + updateCard(form.title, form.description); + }; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ +
+
+ ); +}; + +CardEdit.propTypes = { + cardRes: PropTypes.shape({ current: PropTypes.object }), + updateCard: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + cardRes: state.cardRes +}); + +export default connect( + mapStateToProps, + { updateCard } +)(CardEdit); diff --git a/src/component/card/CardItem.js b/src/component/card/CardItem.js new file mode 100644 index 00000000..04b591ae --- /dev/null +++ b/src/component/card/CardItem.js @@ -0,0 +1,75 @@ +import React, { useEffect } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "react-redux"; +import { setCurrent } from "../../action/CardAction"; + +const CardItem = ({ cardRes: { card }, setCurrent }) => { + // Select icon relative than the card tag + const getIcon = () => { + switch (card.tag) { + case "sport": + return ; + case "art": + return ; + case "fun": + return ; + } + }; + + // To update the card + const handleClick = () => { + setCurrent(card); + }; + + return ( + // The code to equal is 1. so the animation is runing +
+ {/* The code to equal is 0. so the image is load */} + {card.code === 0 && ( +
+ Card Image +
+ )} + + {/* The code to equal is 2. so the audio is play */} + {card.code === 2 && ( +
+ +
+ )} + +
+

+ {getIcon()} + {card.title} +

+

{card.description}

+
+ +
+ +
+
+ ); +}; + +CardItem.propTypes = { + cardRes: PropTypes.shape({ card: PropTypes.object }), + setCurrent: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + cardRes: state.cardRes +}); + +export default connect( + mapStateToProps, + { setCurrent } +)(CardItem); diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..9a85450a --- /dev/null +++ b/src/index.js @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./App"; +import { Provider } from "react-redux"; +import store from "./store"; + +ReactDOM.render( + + + , + document.getElementById("root") +); diff --git a/src/reducer/CardReducer.js b/src/reducer/CardReducer.js new file mode 100644 index 00000000..cae93325 --- /dev/null +++ b/src/reducer/CardReducer.js @@ -0,0 +1,62 @@ +import { + CARD_ERROR, + FETCH_CARDS, + UPDATE_CARD, + SELECT_CARD, + SET_CURRENT +} from "../action/types"; + +const initialState = { + cards: [], + card: null, + index: -1, + current: null, + error: null +}; + +export default (state = initialState, action) => { + switch (action.type) { + case FETCH_CARDS: + return { + ...state, + cards: action.payload + }; + case SELECT_CARD: + // For select random number + const randomNum = Math.round( + action.payload * (state.cards.length - 1) + ); + console.log(randomNum); + return { + ...state, + card: state.cards[randomNum], + index: randomNum + }; + case SET_CURRENT: + return { + ...state, + current: action.payload + }; + case UPDATE_CARD: + // Create new card by the title and description that updated + const newCard = { + ...state.card, + ...action.payload + }; + return { + ...state, + card: newCard, + current: null, + cards: state.cards.map((card, i) => { + return i === state.index ? newCard : card; + }) + }; + case CARD_ERROR: + return { + ...state, + error: action.payload + }; + default: + return state; + } +}; diff --git a/src/reducer/index.js b/src/reducer/index.js new file mode 100644 index 00000000..13001c5c --- /dev/null +++ b/src/reducer/index.js @@ -0,0 +1,6 @@ +import { combineReducers } from "redux"; +import CardReducer from "./CardReducer"; + +export default combineReducers({ + cardRes: CardReducer +}); diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..8c6c5345 --- /dev/null +++ b/src/store.js @@ -0,0 +1,15 @@ +import { createStore, applyMiddleware } from "redux"; +import rootReducer from "./reducer"; +import { composeWithDevTools } from "redux-devtools-extension"; +import thunk from "redux-thunk"; + +const middleware = [thunk]; +const initialState = {}; + +const store = createStore( + rootReducer, + initialState, + composeWithDevTools(applyMiddleware(...middleware)) +); + +export default store; diff --git a/webpack.dev.js b/webpack.dev.js new file mode 100644 index 00000000..4c5903a9 --- /dev/null +++ b/webpack.dev.js @@ -0,0 +1,65 @@ +const path = require("path"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "bundle.js", + path: path.join(__dirname, "dist"), + publicPath: "/" + }, + mode: "development", + devServer: { + hot: true, + port: 3000, + contentBase: path.join(__dirname, "public"), + publicPath: "/", + historyApiFallback: true + }, + module: { + rules: [ + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"] + }, + { + test: /\.js$/, + exclude: /node_modules/, + use: "babel-loader" + }, + { + test: /\.(ttf|woff|woff2)$/, + use: { + loader: "file-loader", + options: { + publicPath: "./assets/font", + outputPath: "./assets/font" + } + } + }, + { + test: /\.(png|jpg|jpeg|gif)$/, + use: { + loader: "file-loader", + options: { + publicPath: "./assets/img", + outputPath: "./assets/img" + } + } + } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + title: "React Card Challenge", + meta: { + description: + "A responsive card based design, to be implemented by React/Redux", + author: "Amin Taghipour ", + "application-name": "HTML/CSS Challenge" + }, + filename: "index.html", + template: "./public/index.html" + }) + ] +}; diff --git a/webpack.prod.js b/webpack.prod.js new file mode 100644 index 00000000..3b03b1f1 --- /dev/null +++ b/webpack.prod.js @@ -0,0 +1,76 @@ +const path = require("path"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const { CleanWebpackPlugin } = require("clean-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "bundle.[contenthash].js", + path: path.join(__dirname, "dist"), + publicPath: "./" + }, + mode: "production", + module: { + rules: [ + // handle the scss file in js + { + test: /\.scss$/, + loader: [ + MiniCssExtractPlugin.loader, + "css-loader", + "sass-loader" + ] + }, + // handle the ES6 js files + { + test: /\.js$/, + exclude: /node_modules/, + use: "babel-loader" + }, + // hanlde the assets files + { + test: /\.(ttf|woff|woff2)$/, + use: { + loader: "file-loader", + options: { + publicPath: "./assets/font", + outputPath: "./assets/font" + } + } + }, + { + test: /\.(png|jpg|jpeg|gif)$/, + use: { + loader: "file-loader", + options: { + publicPath: "./assets/img", + outputPath: "./assets/img" + } + } + } + ] + }, + plugins: [ + // Clean dist folder for each rebuild + new CleanWebpackPlugin(), + + // Create the separate css file + new MiniCssExtractPlugin({ + filename: "styles.[contenthash].css" + }), + + // Config the html file + new HtmlWebpackPlugin({ + title: "React Card Challenge", + meta: { + description: + "A responsive card based design, to be implemented by React & Redux", + author: "Amin Taghipour ", + "application-name": "HTML/CSS Challenge" + }, + filename: "index.html", + template: "./public/index.html" + }) + ] +};