diff --git a/.env b/.env index c46b54b4..286a22a0 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -GITHUB_URL=https://api.github.com/ \ No newline at end of file +GITHUB_URL=https://api.github.com/ +ITUNES_URL=https://itunes.apple.com/ \ No newline at end of file diff --git a/.env.development b/.env.development index c46b54b4..286a22a0 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,2 @@ -GITHUB_URL=https://api.github.com/ \ No newline at end of file +GITHUB_URL=https://api.github.com/ +ITUNES_URL=https://itunes.apple.com/ \ No newline at end of file diff --git a/.env.local b/.env.local index a0152a72..36f757bf 100644 --- a/.env.local +++ b/.env.local @@ -1,2 +1,3 @@ GITHUB_URL=https://api.github.com/ -IS_LOCAL=true \ No newline at end of file +IS_LOCAL=true +ITUNES_URL=https://itunes.apple.com/ \ No newline at end of file diff --git a/app/components/MediaItemCard/index.js b/app/components/MediaItemCard/index.js new file mode 100644 index 00000000..5b767844 --- /dev/null +++ b/app/components/MediaItemCard/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { string } from 'prop-types'; +import { If } from '../If/index'; + +const Card = styled.div` + border-style: solid; + border-width: 1px; + padding: 5px; + border-radius: 5px; + + &:hover { + cursor: pointer; + background: #eaeaea; + } + + &:active { + background: #cacaca; + } +`; + +/** + * MediaItemCard component that displays information about a Media item (like a song). + * It shows the repository's name, full name, and star count. + * + * @date 01/03/2024 - 14:47:28 + * + * @param {Object} props - The component props. + * @param {string} props.trackName - The name of the track. + * @param {string} props.collectionName - The name of the collection. + * @param {string} props.artistName - The name of the artist. + * @param {string} props.country - The name of the country the media is from + * @param {string} props.primaryGenreName - Genre name + * @param {string} props.thumbnailSrc - thumbnail of the media + * @returns {JSX.Element} The RepoCard component displaying the repository information. + */ +export function MediaItemCard({ trackName, collectionName, artistName, country, primaryGenreName, thumbnailSrc }) { + return ( + + + thumbnail + + +
{trackName}
+
+ +
{collectionName}
+
+ +
{artistName}
+
+ +
{country}
+
+ +
{primaryGenreName}
+
+
+ ); +} + +MediaItemCard.propTypes = { + trackName: string, + collectionName: string, + artistName: string, + country: string, + primaryGenreName: string, + thumbnailSrc: string +}; + +export default MediaItemCard; diff --git a/app/containers/SongsContainer/index.js b/app/containers/SongsContainer/index.js new file mode 100644 index 00000000..691f337f --- /dev/null +++ b/app/containers/SongsContainer/index.js @@ -0,0 +1,157 @@ +import React, { memo, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { createStructuredSelector } from 'reselect'; +import { injectSaga } from 'redux-injectors'; +import styled from '@emotion/styled'; +import { IconButton, InputAdornment, OutlinedInput } from '@mui/material'; +import { Search as SearchIcon } from '@mui/icons-material'; +import isEmpty from 'lodash/isEmpty'; +import debounce from 'lodash/debounce'; +import { translate } from '@app/utils'; +import { selectLoading, selectSongName, selectSongsData, selectSongsError } from './selectors'; +import { songsContainerCreators } from './reducer'; +import songsContainerSaga from './saga'; +import { If } from '@app/components/If/index'; +import { For } from '@app/components/For/index'; +import { MediaItemCard } from '@app/components/MediaItemCard/index'; + +const StyledOutlinedInput = styled(OutlinedInput)` + legend { + display: none; + } + > fieldset { + top: 0; + } +`; + +const Grid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +`; + +/** + * SongsContainer component is responsible for fetching and displaying song data fetched from itunes + * @returns {JSX.Element} The SongsContainer component + */ +export function SongsContainer({ + dispatchRequestGetItuneSongs, + dispatchClearItuneSongs, + intl, + songsData, + songsError, + songName, + history, + loading +}) { + useEffect(() => { + if (songName && songsData?.results?.length === 0) { + dispatchRequestGetItuneSongs(songName); + } + }, []); + + const searchSongName = (sName) => { + dispatchRequestGetItuneSongs(sName); + }; + + const handleOnChange = async (sName) => { + if (!isEmpty(sName)) { + searchSongName(sName); + } else { + dispatchClearItuneSongs(); + } + }; + + const debouncedHandleOnChange = debounce(handleOnChange, 200); + + const handleOnSearchIconClick = () => { + searchSongName(songName); + }; + return ( + <> + debouncedHandleOnChange(event.target.value)} + fullWidth + defaultValue={songName} + placeholder={translate('songs_search_input_placeholder')} + endAdornment={ + + + + + + } + /> + 0}> + { + return ( + + ); + }} + /> + + + ); +} + +SongsContainer.propTypes = { + dispatchRequestGetItuneSongs: PropTypes.func, + dispatchClearItuneSongs: PropTypes.func, + intl: PropTypes.object, + songsData: PropTypes.shape({ + resultCount: PropTypes.number, + results: PropTypes.array + }), + songsError: PropTypes.string, + songName: PropTypes.string, + history: PropTypes.object, + loading: PropTypes.bool +}; + +SongsContainer.defaultProps = { + songsData: {}, + songsError: null +}; + +const mapStateToProps = createStructuredSelector({ + loading: selectLoading(), + songsData: selectSongsData(), + songsError: selectSongsError(), + songName: selectSongName() +}); + +// eslint-disable-next-line require-jsdoc +export function mapDispatchToProps(dispatch) { + const { requestGetItuneSongs, clearItuneSongs } = songsContainerCreators; + return { + dispatchRequestGetItuneSongs: (songName) => dispatch(requestGetItuneSongs(songName)), + dispatchClearItuneSongs: () => dispatch(clearItuneSongs()) + }; +} + +const withConnect = connect(mapStateToProps, mapDispatchToProps); + +export default compose( + withConnect, + memo, + injectSaga({ key: 'songsContainer', saga: songsContainerSaga }) +)(SongsContainer); diff --git a/app/containers/SongsContainer/loadable.js b/app/containers/SongsContainer/loadable.js new file mode 100644 index 00000000..c0cba514 --- /dev/null +++ b/app/containers/SongsContainer/loadable.js @@ -0,0 +1,7 @@ +/** + * Asynchronously loads the component for NotFoundPage + */ + +import loadable from '@utils/loadable'; + +export default loadable(() => import('./index')); diff --git a/app/containers/SongsContainer/reducer.js b/app/containers/SongsContainer/reducer.js new file mode 100644 index 00000000..ceb5a478 --- /dev/null +++ b/app/containers/SongsContainer/reducer.js @@ -0,0 +1,44 @@ +/* + * + * HomeContainer reducer + * + */ +import { produce } from 'immer'; +import { createActions } from 'reduxsauce'; +import get from 'lodash/get'; + +export const { Types: songsContainerTypes, Creators: songsContainerCreators } = createActions({ + requestGetItuneSongs: ['songName'], + successGetItuneSongs: ['data'], + failureGetItuneSongs: ['error'], + clearItuneSongs: {} +}); +export const initialState = { songName: null, songsData: {}, songsError: null, loading: null }; + +export const songsContainerReducer = (state = initialState, action) => + produce(state, (draft) => { + switch (action.type) { + case songsContainerTypes.REQUEST_GET_ITUNE_SONGS: + draft.songName = action.songName; + draft.loading = true; + break; + case songsContainerTypes.CLEAR_ITUNE_SONGS: + draft.songName = null; + draft.songsError = null; + draft.songsData = {}; + draft.loading = null; + break; + case songsContainerTypes.SUCCESS_GET_ITUNE_SONGS: + draft.songsData = action.data; + draft.songsError = null; + draft.loading = false; + break; + case songsContainerTypes.FAILURE_GET_ITUNE_SONGS: + draft.songsError = get(action.error, 'message', 'something_went_wrong'); + draft.songsData = null; + draft.loading = false; + break; + } + }); + +export default songsContainerReducer; diff --git a/app/containers/SongsContainer/saga.js b/app/containers/SongsContainer/saga.js new file mode 100644 index 00000000..bc47bdb7 --- /dev/null +++ b/app/containers/SongsContainer/saga.js @@ -0,0 +1,37 @@ +import { put, call, takeLatest } from 'redux-saga/effects'; +import { songsContainerCreators, songsContainerTypes } from './reducer'; +import { getSongs } from '@services/songApi'; + +const { REQUEST_GET_ITUNE_SONGS } = songsContainerTypes; +const { successGetItuneSongs, failureGetItuneSongs } = songsContainerCreators; + +/** + * A saga that handles fetching GitHub repositories based on a given repository name. + * On success, it dispatches a success action with the fetched data. + * On failure, it dispatches a failure action with the error data. + * + * @date 01/03/2024 - 14:47:28 + * + * @param {Object} action - The action object containing the repository name. + * @yields {Effect} The effect of calling the API, and then either the success or failure action. + */ +export function* getItuneSongs(action) { + const response = yield call(getSongs, action.songName); + const { data, ok } = response; + if (ok) { + yield put(successGetItuneSongs(data)); + } else { + yield put(failureGetItuneSongs(data)); + } +} + +/** + * registering events + * @date 04/03/2024 - 12:57:49 + * + * @export + * @returns {{}} + */ +export default function* songsContainerSaga() { + yield takeLatest(REQUEST_GET_ITUNE_SONGS, getItuneSongs); +} diff --git a/app/containers/SongsContainer/selectors.js b/app/containers/SongsContainer/selectors.js new file mode 100644 index 00000000..5fd4be1b --- /dev/null +++ b/app/containers/SongsContainer/selectors.js @@ -0,0 +1,27 @@ +import { createSelector } from 'reselect'; +import get from 'lodash/get'; +import { initialState } from './reducer'; + +/** + * Direct selector to the homeContainer state domain + */ + +export const selectSongsContainerDomain = (state) => state.songsContainer || initialState; + +/** + * Other specific selectors + */ + +/** + * Default selector used by HomeContainer + */ + +export const selectSongsData = () => + createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songsData')); + +export const selectLoading = () => createSelector(selectSongsContainerDomain, (substate) => get(substate, 'loading')); + +export const selectSongsError = () => + createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songsError')); + +export const selectSongName = () => createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songName')); diff --git a/app/create-root-reducer.js b/app/create-root-reducer.js index 6d8d1ec6..51b676c3 100644 --- a/app/create-root-reducer.js +++ b/app/create-root-reducer.js @@ -5,6 +5,7 @@ import { combineReducers } from 'redux'; import LanguageProviderReducer from '@containers/LanguageProvider/reducer'; import HomeContainerReducer from '@containers/HomeContainer/reducer'; +import SongsContainerReducer from './containers/SongsContainer/reducer'; /** * Merges the main reducer with the router state and dynamically injected reducers @@ -13,6 +14,7 @@ export default function createRootReducer(injectedReducer = {}) { return combineReducers({ ...injectedReducer, language: LanguageProviderReducer, - homeContainer: HomeContainerReducer + homeContainer: HomeContainerReducer, + songsContainer: SongsContainerReducer }); } diff --git a/app/routeConfig.js b/app/routeConfig.js index 77ae319f..4a83833f 100644 --- a/app/routeConfig.js +++ b/app/routeConfig.js @@ -1,12 +1,18 @@ import routeConstants from '@utils/routeConstants'; import NotFound from '@app/containers/NotFoundPage/loadable'; import HomeContainer from '@app/containers/HomeContainer/loadable'; +import SongsContainer from './containers/SongsContainer/loadable'; export const routeConfig = { repos: { component: HomeContainer, ...routeConstants.repos }, + songs: { + component: SongsContainer, + route: '/songs', + exact: true + }, notFoundPage: { component: NotFound, route: '/' diff --git a/app/services/songApi.js b/app/services/songApi.js new file mode 100644 index 00000000..3697aa64 --- /dev/null +++ b/app/services/songApi.js @@ -0,0 +1,7 @@ +import { generateApiClient } from '@utils/apiUtils'; +const songApi = generateApiClient('itunes'); + +/** + * @see https://github.com/elbywan/wretch?tab=readme-ov-file#http-methods- + */ +export const getSongs = (songName) => songApi.get(`search?term=${songName}`); diff --git a/app/translations/en.json b/app/translations/en.json index 8769e471..062c2b67 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -15,5 +15,6 @@ "something_went_wrong": "Sorry. Something went wrong! Please try again in sometime.", "stories": "Go to Storybook", "wednesday_solutions": "Wednesday Solutions", - "default_template": "wednesday-solutions/react-template" + "default_template": "wednesday-solutions/react-template", + "songs_search_input_placeholder": "Please enter a song name" } diff --git a/app/utils/apiUtils.js b/app/utils/apiUtils.js index 2bdcdce4..bb74cf85 100644 --- a/app/utils/apiUtils.js +++ b/app/utils/apiUtils.js @@ -5,10 +5,12 @@ import { mapKeysDeep } from './index'; const API_TYPES = { GITHUB: 'github', + ITUNES: 'itunes', DEFAULT: 'default' }; const apiClients = { [API_TYPES.GITHUB]: null, + [API_TYPES.ITUNES]: null, [API_TYPES.DEFAULT]: null }; @@ -39,6 +41,12 @@ export const generateApiClient = (type = 'github') => { apiClients[type] = createApiClientWithTransForm(process.env.GITHUB_URL); return apiClients[type]; } + if (type === API_TYPES.ITUNES) { + // store this value for time to come + // eslint-disable-next-line immutable/no-mutation + apiClients[type] = createApiClientWithTransForm(process.env.ITUNES_URL); + return apiClients[type]; + } // store this value for time to come // eslint-disable-next-line immutable/no-mutation apiClients.default = createApiClientWithTransForm(process.env.GITHUB_URL); diff --git a/internals/webpack/webpack.config.base.js b/internals/webpack/webpack.config.base.js index 77edac2c..09ef8fb3 100644 --- a/internals/webpack/webpack.config.base.js +++ b/internals/webpack/webpack.config.base.js @@ -142,6 +142,7 @@ module.exports = (options) => ({ // optimizationLevel: 7 }, pngquant: { + enabled: false, quality: [0.65, 0.9], speed: 4 },