diff --git a/src/App.css b/src/App.css index a64d7ea..89c2d86 100755 --- a/src/App.css +++ b/src/App.css @@ -1,4 +1,6 @@ .App { + display: flex; + flex-direction: column; text-align: center; } @@ -49,6 +51,14 @@ img { border: 1px solid #ccc; } +.favorite{ + cursor: pointer; + margin: 0.5rem auto; +} +.favorite:hover { + fill: rgb(250, 218, 148); +} + @keyframes spin { 0% { transform: rotate(0deg); @@ -65,4 +75,85 @@ img { to { transform: rotate(360deg); } + + +} + +.options-nav { + display: flex; +} + +/* Favorites list section */ +.favorites-list-section { + /* padding: 1rem; */ + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.favorite-item { + background-color: rgb(242, 242, 242); + border-radius: 2px; + margin: 1rem; + padding:1rem; + display: flex; + justify-content: space-between; } + +.favorite-item .action-container { + cursor: pointer; + display: flex; + justify-content: space-between; + /* height: 200px; */ +} + +.favorite-item .action-container > * { + margin: 5px; +} + +.favorite-item .img-container { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 150px; + height: 150px; +} + +.favorite-item .img-container > img { + max-height: 100%; + max-width: 100%; + object-position: center; + object-fit: cover; +} + +.favorite-item .action-container .informations { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} + +.favorite-item .informations > .title { + font-weight: bold; + font-size: 21px; +} + +.favorite-item .informations > .description { + text-overflow: ellipsis; + overflow: hidden; + /* CSS properties for ellipsis overflow after 5 lines of text */ + display: block; + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + max-height: 5 * 1.6rem; +} + +@media screen and (max-width: 600px) { + .favorite-item { + flex-direction: column; + } + + .favorite-item .action-container { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index cdfec73..51b0b91 100755 --- a/src/App.js +++ b/src/App.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useContext } from "react"; import { Button, Dialog, @@ -12,66 +12,30 @@ import "./App.css"; import EpisodeList from "./components/EpisodeList"; import UserForm from "./components/UserForm"; import LoadingStatus from "./components/LoadingStatus"; +import FavoriteDialog from "./components/FavoriteDialog"; +import { useRef } from "react"; +import SearchHistory from "./components/SearchHistory"; +import { Context } from "./context/Context"; -const App = ({ fetching }) => { - const [fetched, setFetched] = useState({}); - const [onFetching, setFetching] = useState(false); - const [previousFeeds, setPreviousFeeds] = useState([]); - const [past, setPast] = useState(false); - const [error, setError] = useState(false); +const App = () => { + const { state, dispatch } = useContext(Context); - const getFeed = (event) => { - setFetching((prev) => !prev); - if (event.preventDefault != null) - event.preventDefault(); - const feed_url = event.target.elements.feed_url.value; - const Parser = require("rss-parser"); - const parser = new Parser({ - customFields: { - item: [["enclosure", { keepArray: true }]], - }, - }); + const favoritesPopUpRef = useRef(); - const CORS_PROXY = "https://cors-anywhere.herokuapp.com/"; - - if (feed_url) { - const loadRSS = async () => { - try { - const feed = await parser.parseURL(CORS_PROXY + feed_url); - setFetched({ - episodes: feed.items, - program_title: feed.title, - program_image: feed.image.url, - program_description: feed.description, - }); - setFetching((prev) => !prev); - setPreviousFeeds([...new Set([...previousFeeds, feed_url])]); - setPast(true); - - return setError(false); - } catch (error) { - setFetching(false); - setError(true); - - return error; - } - }; - - return loadRSS(); - } else { - return; - } - }; + // Check if the current feed is filled. + const isCurrentFeedFetched = () => { + return state.currentFeed.program_link && state.currentFeed.program_link !== ''; + } const handleClose = () => { - setFetching(false); - setError(false); + dispatch({ type: 'SET_FETCHING', payload: false }); + dispatch({ type: 'SET_ERROR', payload: false }); }; const renderAlert = () => (
@@ -95,23 +59,27 @@ const App = ({ fetching }) => {

quick-feed

- setFetching(true)} - past={past} - previous_feeds={[...previousFeeds]} - /> - {error ? renderAlert() :
} - {!past ?

Please enter an RSS feed

:
} - + + + {isCurrentFeedFetched() ? <> + + + :

Please enter an RSS feed

} + + {state.error ? renderAlert() :
} + - + {/* Favorite feeds list dialog component */} +
); }; diff --git a/src/components/Episode.jsx b/src/components/Episode.jsx index aa79690..32bb374 100644 --- a/src/components/Episode.jsx +++ b/src/components/Episode.jsx @@ -32,27 +32,4 @@ const Episode = ({ link, title }) => { ); }; -// class Episode extends Component { -// divStyles = { -// width: "77vw", -// float: "right", -// marginRight: "1vw", -// }; -// render() { -// return ( -//
-// -// {this.props.title} -// -// -//

{this.props.title}

-//
-//
-// ); -// } -// } - export default Episode; diff --git a/src/components/EpisodeList.jsx b/src/components/EpisodeList.jsx index 280da89..3208510 100644 --- a/src/components/EpisodeList.jsx +++ b/src/components/EpisodeList.jsx @@ -1,63 +1,81 @@ -import React, { Component } from "react"; +import React, { useContext } from "react"; +import FavoriteButton from "./FavoriteButton"; import Episode from "./Episode"; +import { useState } from "react"; +import { Context } from "../context/Context"; -class EpisodeList extends Component { - cardStyle = { - width: "20vw", - float: "left", - }; +const EpisodeList = ({ + program_title, + program_description, + program_image, + episodes, + program_link + }) => { + + const { state, dispatch } = useContext(Context); + + // eslint-disable-next-line no-unused-vars + const [cardStyle, setCardStyle] = useState({width: "20vw", float: "left"}); - render() { - const { + const toggleFavorite = () => { + const feedData = { program_title, - program_description, program_image, - episodes, - } = this.props; + program_description, + program_link + }; + isFavoriteSelected() ? dispatch({type: 'REMOVE_FAVORITE_FEED', payload: feedData }) : dispatch({type: 'ADD_FAVORITE_FEED', payload: feedData }); + } - return ( -
- {episodes ? ( -
- -
- {program_title} { + return state.favoriteFeeds.some(el => el.program_link === program_link); + } + + return ( +
+ {episodes ? ( +
+ +
+ {program_title} +
+
{program_title}
+ +
-
-
{program_title}
-
-
- {episodes.map((episode, i) => ( - - ))}
- ) : ( -
- )} -
- ); - } + {episodes.map((episode, i) => ( + + ))} +
+ ) : ( +
+ )} +
+ ); } export default EpisodeList; diff --git a/src/components/FavoriteButton.jsx b/src/components/FavoriteButton.jsx new file mode 100644 index 0000000..76a4574 --- /dev/null +++ b/src/components/FavoriteButton.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import SvgIcon from '@material-ui/core/SvgIcon'; +import amber from '@material-ui/core/colors/amber'; +import grey from '@material-ui/core/colors/grey'; +import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; + +const FavoriteButton = ({selected, onClickAction}) => { + + const theme = createMuiTheme({ + palette: { + primary: { + light: amber[100], + main: amber[500], + dark: amber[800], + contrastText: '#fff', + }, + secondary: { + light: grey[100], + main: grey[400], + dark: grey[800], + contrastText: '#000', + } + }, + }); + + const StarIcon = (props) => { + return ( + + + + ); + }; + + return ( +
+ + + +
+ ); +}; + +export default FavoriteButton; \ No newline at end of file diff --git a/src/components/FavoriteDialog.jsx b/src/components/FavoriteDialog.jsx new file mode 100644 index 0000000..46dc444 --- /dev/null +++ b/src/components/FavoriteDialog.jsx @@ -0,0 +1,68 @@ +import React, { useContext, useState } from 'react'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import "../App.css"; +import FavoriteItem from './FavoriteItem'; +import { Context } from '../context/Context'; +const { forwardRef, useImperativeHandle } = React; + +const FavoriteDialog = forwardRef((props, ref) => { + + const {state} = useContext(Context); + + const [open, setOpen] = useState(false); + + const handleClose = () => { + setOpen(false); + } + const handleClickOpen = () => { + setOpen(true); + } + + useImperativeHandle(ref, () => { return { + handleClickOpen: handleClickOpen, + handleClose: handleClose + }}); + + return ( +
+ handleClose()} + scroll={'paper'} + maxWidth={'md'} + aria-labelledby="scroll-dialog-title" + > + Favorite feeds + +
+ + { + state.favoriteFeeds.length > 0 ? state.favoriteFeeds.map((item, i) => { + return ( + {handleClose()}} + key={i} index={i} />) + }) + : +
No favorites
+ } + +
+
+ + + +
+
+ ); +}); + + +export default FavoriteDialog; \ No newline at end of file diff --git a/src/components/FavoriteItem.jsx b/src/components/FavoriteItem.jsx new file mode 100644 index 0000000..a1625ff --- /dev/null +++ b/src/components/FavoriteItem.jsx @@ -0,0 +1,61 @@ +import React, { useContext } from "react"; +import "../App.css"; +import { Context } from "../context/Context"; +import { getFeed } from "./utils/httpRequests"; +import FavoriteButton from "./FavoriteButton"; + +const FavoriteItem = ({item, index, closePopUp}) => { + + const { state, dispatch } = useContext(Context); + + const removeFavorite = () => { + if (isFavoriteSelected) { + dispatch({type: 'REMOVE_FAVORITE_FEED', payload: item }); + } + } + + const seeFavorite = (link) => { + fetchFeed({target: {elements: {feed_url: {value: link}}}}); + closePopUp(); + }; + + const fetchFeed = async (event) => { + dispatch({ type: 'SET_FETCHING', payload: true }); + try { + const feed = await getFeed(event); + dispatch({type: 'SET_CURRENT_FEED', payload: feed}) + dispatch({ type: 'SET_ERROR', payload: false }); + return + } catch (error) { + dispatch({ type: 'SET_ERROR', payload: true }); + return error; + } finally { + dispatch({ type: 'SET_FETCHING', payload: false }); + } + } + + const isFavoriteSelected = () => { + return state.favoriteFeeds.some(el => el.program_link === item.program_link); + } + + return ( +
+
seeFavorite(item.program_link)}> +
+ {item.program_title} +
+
+

{item.program_title}

+

{item.program_description}

+
+
+ +
+ ) +} + +export default FavoriteItem; \ No newline at end of file diff --git a/src/components/SearchHistory.jsx b/src/components/SearchHistory.jsx index 1856814..73dd42f 100644 --- a/src/components/SearchHistory.jsx +++ b/src/components/SearchHistory.jsx @@ -1,7 +1,11 @@ -import React from "react"; +import React, { useContext } from "react"; import { Button, Menu, MenuItem } from "@material-ui/core"; import "../App.css"; -export default function SearchHistory(props) { +import { Context } from "../context/Context"; +import { getFeed } from "./utils/httpRequests"; + +export default function SearchHistory() { + const { state, dispatch } = useContext(Context); const [anchorEl, setAnchorEl] = React.useState(null); const handleClick = (event) => { @@ -9,8 +13,8 @@ export default function SearchHistory(props) { }; const handleClose = (event) => { - if (event.currentTarget.innerText != '') - props.getFeed({target: {elements: {feed_url: {value: event.currentTarget.innerText}}}}); + if (event.currentTarget.innerText !== '') + fetchFeed({target: {elements: {feed_url: {value: event.currentTarget.innerText}}}}); setAnchorEl(null); }; @@ -23,9 +27,24 @@ export default function SearchHistory(props) { }; const renderMenuItems = () => { - return
{props.history.map(renderItem)}
; + return
{state.previousFeeds.map(renderItem)}
; }; + const fetchFeed = async (event) => { + dispatch({ type: 'SET_FETCHING', payload: true }); + try { + const feed = await getFeed(event); + dispatch({type: 'SET_CURRENT_FEED', payload: feed}); + dispatch({ type: 'SET_ERROR', payload: false }); + return + } catch (error) { + dispatch({ type: 'SET_ERROR', payload: true }); + return error; + } finally { + dispatch({ type: 'SET_FETCHING', payload: false }); + } + } + return (
- {past ? :
}
); diff --git a/src/components/utils/formatUtils.js b/src/components/utils/formatUtils.js new file mode 100644 index 0000000..f62a7e0 --- /dev/null +++ b/src/components/utils/formatUtils.js @@ -0,0 +1,13 @@ +// Formatting method for URLs to avoid having duplicate favorites in localstorage as the key used is the URL +export const formatUrl = (url) => { + let urlSequence; + let finalUrl; + if (url.includes('://')) { + urlSequence = url.split('://')[1]; + finalUrl = 'https://' + urlSequence; + } else { + finalUrl = 'https://' + url; + } + + return finalUrl; +}; \ No newline at end of file diff --git a/src/components/utils/httpRequests.js b/src/components/utils/httpRequests.js new file mode 100644 index 0000000..80fa11e --- /dev/null +++ b/src/components/utils/httpRequests.js @@ -0,0 +1,18 @@ +import { formatUrl } from "./formatUtils"; + +export const getFeed = async (event) => { + + if (event.preventDefault != null) + event.preventDefault(); + const feed_url = formatUrl(event.target.elements.feed_url.value); + const Parser = require("rss-parser"); + const parser = new Parser({ + customFields: { + item: [["enclosure", { keepArray: true }]], + }, + }); + + const CORS_PROXY = "https://cors-anywhere.herokuapp.com/"; + + return await parser.parseURL(CORS_PROXY + feed_url); +}; \ No newline at end of file diff --git a/src/context/Context.js b/src/context/Context.js new file mode 100644 index 0000000..3d1a975 --- /dev/null +++ b/src/context/Context.js @@ -0,0 +1,46 @@ +import React, { useReducer, createContext } from "react"; +import { feedReducer } from "./reducers/FeedReducer"; + +const initFavorites = () => { + let favorites = []; + for (let [key, value] of Object.entries(localStorage)) { + if (key.startsWith('favorite-')) { + favorites.push(JSON.parse(value)); + } + } + return favorites; +} + +// Initial state +const initialState = { + currentFeed: { + episodes: [], + program_title: '', + program_image: '', + program_description: '', + program_link: '' + }, + onFetching: false, + previousFeeds: [], + error: false, + favoriteFeeds: initFavorites() +}; + +// Create context +const Context = createContext({}); + +// Combine reducer function +const combineReducers = (...reducers) => (state, action) => { + for (let i = 0; i < reducers.length; i++) state = reducers[i](state, action); + return state; +}; + +// Context provider +const Provider = ({ children }) => { + const [state, dispatch] = useReducer(combineReducers(feedReducer), initialState); + const value = { state, dispatch }; + + return {children}; +}; + +export { Context, Provider }; \ No newline at end of file diff --git a/src/context/reducers/FeedReducer.js b/src/context/reducers/FeedReducer.js new file mode 100644 index 0000000..affa462 --- /dev/null +++ b/src/context/reducers/FeedReducer.js @@ -0,0 +1,26 @@ +export function feedReducer(state, action) { + switch (action.type) { + case "SET_CURRENT_FEED": + // Update current feed and previous feed at the same time. + return { ...state, currentFeed: { + episodes: action.payload.items, + program_title: action.payload.title, + program_image: action.payload.image.url, + program_description: action.payload.description, + program_link: action.payload.feedUrl + }, previousFeeds: [...new Set([...state.previousFeeds, action.payload.feedUrl])] + }; + case "SET_FETCHING": + return { ...state, onFetching: action.payload }; + case "SET_ERROR": + return { ...state, error: action.payload }; + case "ADD_FAVORITE_FEED": + localStorage.setItem(`favorite-${action.payload.program_link}`, JSON.stringify(action.payload)); + return { ...state, favoriteFeeds: [...state.favoriteFeeds, action.payload] }; + case "REMOVE_FAVORITE_FEED": + localStorage.removeItem(`favorite-${action.payload.program_link}`); + return { ...state, favoriteFeeds: state.favoriteFeeds.filter(element => element.program_link !== action.payload.program_link) }; + default: + return state; + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 0c5e75d..2ed2936 100755 --- a/src/index.js +++ b/src/index.js @@ -3,8 +3,13 @@ import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; +import { Provider } from './context/Context'; -ReactDOM.render(, document.getElementById('root')); +ReactDOM.render( + + + , +document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls.