├── .gitignore ├── .idea ├── encodings.xml ├── jsLibraryMappings.xml ├── misc.xml ├── modules.xml ├── vcs.xml ├── workspace.xml └── youtube-react.iml ├── README.md ├── images ├── youtube-react-home-feed.png ├── youtube-react-watch-1.png └── youtube-react-watch-2.png ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.js ├── __tests__ │ ├── App.unit.test.js │ └── __snapshots__ │ │ └── App.unit.test.js.snap ├── assets │ └── images │ │ └── logo.jpg ├── components │ ├── AppLayout │ │ ├── AppLayout.js │ │ ├── AppLayout.scss │ │ └── __tests__ │ │ │ ├── AppLayout.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── AppLayout.unit.test.js.snap │ ├── InfiniteScroll │ │ ├── InfiniteScroll.js │ │ ├── InfiniteScroll.scss │ │ └── __tests__ │ │ │ ├── InfiniteScroll.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── InfiniteScroll.unit.test.js.snap │ ├── Rating │ │ ├── Rating.js │ │ ├── Rating.scss │ │ └── __tests__ │ │ │ ├── Rating.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── Rating.unit.test.js.snap │ ├── RelatedVideos │ │ ├── NextUpVideo │ │ │ ├── NextUpVideo.js │ │ │ ├── NextUpVideo.scss │ │ │ └── __tests__ │ │ │ │ ├── NextUpVideo.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── NextUpVideo.unit.test.js.snap │ │ ├── RelatedVideos.js │ │ ├── RelatedVideos.scss │ │ └── __tests__ │ │ │ ├── RelatedVideos.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── RelatedVideos.unit.test.js.snap │ ├── ScrollToTop │ │ └── ScrollToTop.js │ ├── Video │ │ ├── Video.js │ │ ├── Video.scss │ │ └── __tests__ │ │ │ ├── Video.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── Video.unit.test.js.snap │ ├── VideoGrid │ │ ├── VideoGrid.js │ │ ├── VideoGrid.scss │ │ ├── VideoGridHeader │ │ │ ├── VideoGridHeader.css │ │ │ ├── VideoGridHeader.js │ │ │ ├── VideoGridHeader.scss │ │ │ └── __tests__ │ │ │ │ ├── VideoGridHeader.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── VideoGridHeader.unit.test.js.snap │ │ └── __tests__ │ │ │ ├── VideoGrid.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── VideoGrid.unit.test.js.snap │ ├── VideoInfoBox │ │ ├── VideoInfoBox.js │ │ ├── VideoInfoBox.scss │ │ └── __tests__ │ │ │ ├── VideoInfoBox.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── VideoInfoBox.unit.test.js.snap │ ├── VideoList │ │ ├── VideoList.js │ │ └── VideoList.scss │ ├── VideoMetadata │ │ ├── VideoMetadata.js │ │ ├── VideoMetadata.scss │ │ └── __tests__ │ │ │ ├── VideoMetadata.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── VideoMetadata.unit.test.js.snap │ └── VideoPreview │ │ ├── VideoPreview.js │ │ ├── VideoPreview.scss │ │ └── __tests__ │ │ ├── VideoPreview.unit.test.js │ │ └── __snapshots__ │ │ └── VideoPreview.unit.test.js.snap ├── containers │ ├── Comments │ │ ├── AddComment │ │ │ ├── AddComment.js │ │ │ ├── AddComment.scss │ │ │ └── __tests__ │ │ │ │ ├── AddComment.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── AddComment.unit.test.js.snap │ │ ├── Comment │ │ │ ├── Comment.js │ │ │ ├── Comment.scss │ │ │ └── __tests__ │ │ │ │ ├── Comment.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── Comment.unit.test.js.snap │ │ ├── Comments.js │ │ ├── CommentsHeader │ │ │ ├── CommentsHeader.js │ │ │ ├── CommentsHeader.scss │ │ │ └── __tests__ │ │ │ │ ├── CommentsHeader.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── CommentsHeader.unit.test.js.snap │ │ └── __tests__ │ │ │ ├── Comments.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── Comments.unit.test.js.snap │ ├── HeaderNav │ │ ├── HeaderNav.js │ │ ├── HeaderNav.scss │ │ └── __tests__ │ │ │ ├── HeaderNav.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── HeaderNav.unit.test.js.snap │ ├── Home │ │ ├── Home.js │ │ ├── Home.scss │ │ └── HomeContent │ │ │ ├── HomeContent.js │ │ │ ├── HomeContent.scss │ │ │ └── __tests__ │ │ │ ├── HomeContent.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── HomeContent.unit.test.js.snap │ ├── Search │ │ ├── Search.js │ │ └── Search.scss │ ├── SideBar │ │ ├── SideBar.js │ │ ├── SideBar.scss │ │ ├── SideBarFooter │ │ │ ├── SideBarFooter.js │ │ │ ├── SideBarFooter.scss │ │ │ └── __tests__ │ │ │ │ ├── SideBarFooter.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── SideBarFooter.unit.test.js.snap │ │ ├── SideBarHeader │ │ │ ├── SideBarHeader.js │ │ │ ├── SideBarHeader.scss │ │ │ └── __tests__ │ │ │ │ ├── SideBarHeader.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── SideBarHeader.unit.test.js.snap │ │ ├── SideBarItem │ │ │ ├── SideBarItem.js │ │ │ ├── SideBarItem.scss │ │ │ └── __tests__ │ │ │ │ ├── SideBarItem.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── SideBarItem.unit.test.js.snap │ │ ├── Subscriptions │ │ │ ├── Subscription │ │ │ │ ├── Subscription.js │ │ │ │ ├── Subscription.scss │ │ │ │ └── __tests__ │ │ │ │ │ ├── Subscription.unit.test.js │ │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Subscription.unit.test.js.snap │ │ │ ├── Subscriptions.js │ │ │ └── __tests__ │ │ │ │ ├── Subscriptions.unit.test.js │ │ │ │ └── __snapshots__ │ │ │ │ └── Subscriptions.unit.test.js.snap │ │ └── __tests__ │ │ │ ├── SideBar.unit.test.js │ │ │ └── __snapshots__ │ │ │ └── SideBar.unit.test.js.snap │ ├── Trending │ │ └── Trending.js │ └── Watch │ │ ├── Watch.js │ │ ├── WatchContent │ │ ├── WatchContent.js │ │ └── WatchContent.scss │ │ └── __tests__ │ │ ├── Watch.unit.test.js │ │ └── __snapshots__ │ │ └── Watch.unit.test.js.snap ├── index.js ├── registerServiceWorker.js ├── services │ ├── date │ │ ├── __tests__ │ │ │ ├── date-format.parse.unit.test.js │ │ │ └── date-format.videoDuration.unit.test.js │ │ └── date-format.js │ ├── number │ │ ├── __tests__ │ │ │ └── number-format.unit.test.js │ │ └── number-format.js │ └── url │ │ └── index.js ├── setupTests.js ├── store │ ├── actions │ │ ├── api.js │ │ ├── comment.js │ │ ├── index.js │ │ ├── search.js │ │ ├── video.js │ │ └── watch.js │ ├── api │ │ ├── youtube-api-response-types.js │ │ └── youtube-api.js │ ├── configureStore.js │ ├── reducers │ │ ├── __tests__ │ │ │ ├── api.unit.test.js │ │ │ ├── responses │ │ │ │ └── MOST_POPULAR_SUCCESS.json │ │ │ ├── states │ │ │ │ └── MOST_POPULAR_SUCCESS.json │ │ │ └── videos.unit.test.js │ │ ├── api.js │ │ ├── channels.js │ │ ├── comments.js │ │ ├── index.js │ │ ├── search.js │ │ └── videos.js │ └── sagas │ │ ├── comment.js │ │ ├── index.js │ │ ├── search.js │ │ ├── video.js │ │ └── watch.js └── styles │ └── _shared.scss └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.css 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 137 | 138 | 139 | 140 | e 141 | getVideoId 142 | fetchWatchDetails 143 | getVideoById 144 | UserComment 145 | 48px 146 | getSearchParam 147 | SideBar 148 | props 149 | 1.3 150 | align 151 | googleA 152 | video.statistics 153 | getShortNu 154 | fetchMoreVideos 155 | <Link 156 | .add-comment 157 | flex-shrink 158 | .comment 159 | chrome 160 | scroll 161 | react-router 162 | pathname.incl 163 | getShortNumber 164 | watchMostPopular 165 | MOST_POP 166 | console.log 167 | getShortn 168 | enzyme 169 | Waypoint 170 | 171 | 172 | Comment 173 | googleLibraryLoaded 174 | YOUTUBE_LIBRARY_LOADED 175 | " 176 | buildVideoCategoriesRequest 177 | iso8601DurationString 178 | props 179 | 180 | 181 | 182 | 184 | 185 | 240 | 241 | 242 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 34 | 35 | 36 | 37 | {/* 5 */} 38 | 39 | 40 | {/* 6 */} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {/* 7*/} 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | onInputChange = (event) => { 62 | this.setState({ 63 | query: event.target.value, 64 | }); 65 | }; 66 | 67 | onSubmit = () => { 68 | const escapedSearchQuery = encodeURI(this.state.query); 69 | this.props.history.push(`/results?search_query=${escapedSearchQuery}`); 70 | }; 71 | } 72 | 73 | export default withRouter(HeaderNav); 74 | -------------------------------------------------------------------------------- /src/containers/HeaderNav/HeaderNav.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/shared.scss'; 2 | /* 3 | The .ui.menu is needed otherwise there's a more specific rule from semantic UI 4 | we use this rule to tweak the SCSS 5 | */ 6 | .ui.menu.top-menu { 7 | border: none; 8 | .logo { 9 | width: $sidebar-left-width; 10 | } 11 | .nav-container { 12 | flex-grow: 1; 13 | padding: 0; 14 | 15 | .search-input { 16 | padding-left: 0; 17 | width: 33%; 18 | 19 | form { 20 | width: 100%; 21 | } 22 | } 23 | } 24 | 25 | .header-icon { 26 | color: #a0a0a0; 27 | } 28 | } -------------------------------------------------------------------------------- /src/containers/HeaderNav/__tests__/HeaderNav.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import HeaderNav from '../HeaderNav'; 4 | 5 | describe('HeaderNav', () => { 6 | test('renders', () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/containers/HeaderNav/__tests__/__snapshots__/HeaderNav.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HeaderNav renders 1`] = ` 4 | 5 | 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/containers/Home/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from "react-redux"; 3 | import * as videoActions from "../../store/actions/video"; 4 | import './Home.scss'; 5 | import {SideBar} from '../SideBar/SideBar'; 6 | import HomeContent from './HomeContent/HomeContent'; 7 | import {bindActionCreators} from 'redux'; 8 | import {getYoutubeLibraryLoaded} from '../../store/reducers/api'; 9 | import {getVideoCategoryIds, videoCategoriesLoaded, videosByCategoryLoaded} from '../../store/reducers/videos'; 10 | 11 | class Home extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { 15 | categoryIndex: 0, 16 | }; 17 | } 18 | 19 | render() { 20 | return ( 21 | 22 | 23 | 26 | 27 | ); 28 | } 29 | 30 | componentDidMount() { 31 | if (this.props.youtubeLibraryLoaded) { 32 | this.fetchCategoriesAndMostPopularVideos(); 33 | } 34 | } 35 | 36 | componentDidUpdate(prevProps) { 37 | if (this.props.youtubeLibraryLoaded !== prevProps.youtubeLibraryLoaded) { 38 | this.fetchCategoriesAndMostPopularVideos(); 39 | } else if (this.props.videoCategories !== prevProps.videoCategories) { 40 | this.fetchVideosByCategory(); 41 | } 42 | } 43 | 44 | fetchVideosByCategory() { 45 | const categoryStartIndex = this.state.categoryIndex; 46 | const categories = this.props.videoCategories.slice(categoryStartIndex, categoryStartIndex + 3); 47 | this.props.fetchMostPopularVideosByCategory(categories); 48 | this.setState(prevState => { 49 | return { 50 | categoryIndex: prevState.categoryIndex + 3, 51 | }; 52 | }); 53 | } 54 | 55 | fetchCategoriesAndMostPopularVideos() { 56 | this.props.fetchMostPopularVideos(); 57 | this.props.fetchVideoCategories(); 58 | } 59 | 60 | bottomReachedCallback = () => { 61 | if (!this.props.videoCategoriesLoaded) { 62 | return; 63 | } 64 | this.fetchVideosByCategory(); 65 | }; 66 | 67 | shouldShowLoader() { 68 | if (this.props.videoCategoriesLoaded && this.props.videosByCategoryLoaded) { 69 | return this.state.categoryIndex < this.props.videoCategories.length; 70 | } 71 | return false; 72 | } 73 | } 74 | 75 | function mapStateToProps(state) { 76 | return { 77 | youtubeLibraryLoaded: getYoutubeLibraryLoaded(state), 78 | videoCategories: getVideoCategoryIds(state), 79 | videoCategoriesLoaded: videoCategoriesLoaded(state), 80 | videosByCategoryLoaded: videosByCategoryLoaded(state), 81 | }; 82 | } 83 | 84 | function mapDispatchToProps(dispatch) { 85 | const fetchMostPopularVideos = videoActions.mostPopular.request; 86 | const fetchVideoCategories = videoActions.categories.request; 87 | const fetchMostPopularVideosByCategory = videoActions.mostPopularByCategory.request; 88 | return bindActionCreators({fetchMostPopularVideos, fetchVideoCategories, fetchMostPopularVideosByCategory}, dispatch); 89 | } 90 | 91 | export default connect(mapStateToProps, mapDispatchToProps)(Home); -------------------------------------------------------------------------------- /src/containers/Home/Home.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/shared.scss'; 2 | 3 | .home { 4 | margin-left: $sidebar-left-width; 5 | display: grid; 6 | grid: auto / auto; 7 | justify-content: center; 8 | } 9 | @media all and (min-width: 476px) { 10 | .responsive-video-grid-container { 11 | max-width: 240px; 12 | } 13 | } 14 | 15 | @media all and (min-width: 700px) { 16 | .responsive-video-grid-container { 17 | max-width: 472px; 18 | } 19 | } 20 | 21 | @media all and (min-width: 900px) { 22 | .responsive-video-grid-container { 23 | max-width: 667px; 24 | } 25 | } 26 | 27 | @media all and (min-width: 1096px) { 28 | .responsive-video-grid-container { 29 | max-width: 864px; 30 | } 31 | } 32 | 33 | @media all and (min-width: 1370px) { 34 | .responsive-video-grid-container { 35 | max-width: 1096px; 36 | } 37 | } 38 | 39 | @media all and (min-width: 1370px) { 40 | .responsive-video-grid-container { 41 | max-width: 1096px; 42 | } 43 | } 44 | 45 | @media all and (min-width: 1560px) { 46 | .responsive-video-grid-container { 47 | max-width: 1284px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/containers/Home/HomeContent/HomeContent.js: -------------------------------------------------------------------------------- 1 | import {VideoGrid} from '../../../components/VideoGrid/VideoGrid'; 2 | import React from 'react'; 3 | import './HomeContent.scss'; 4 | import {getMostPopularVideos, getVideosByCategory} from '../../../store/reducers/videos'; 5 | import {connect} from 'react-redux'; 6 | import {InfiniteScroll} from '../../../components/InfiniteScroll/InfiniteScroll'; 7 | 8 | const AMOUNT_TRENDING_VIDEOS = 12; 9 | 10 | export class HomeContent extends React.Component { 11 | render() { 12 | const trendingVideos = this.getTrendingVideos(); 13 | const categoryGrids = this.getVideoGridsForCategories(); 14 | 15 | return ( 16 |
17 |
18 | 19 | 20 | {categoryGrids} 21 | 22 |
23 |
24 | ); 25 | } 26 | 27 | getTrendingVideos() { 28 | return this.props.mostPopularVideos.slice(0, AMOUNT_TRENDING_VIDEOS); 29 | } 30 | 31 | getVideoGridsForCategories() { 32 | const categoryTitles = Object.keys(this.props.videosByCategory || {}); 33 | return categoryTitles.map((categoryTitle,index) => { 34 | const videos = this.props.videosByCategory[categoryTitle]; 35 | // the last video grid element should not have a divider 36 | const hideDivider = index === categoryTitles.length - 1; 37 | return ; 38 | }); 39 | } 40 | } 41 | 42 | function mapStateToProps(state) { 43 | return { 44 | videosByCategory: getVideosByCategory(state), 45 | mostPopularVideos: getMostPopularVideos(state), 46 | }; 47 | } 48 | export default connect(mapStateToProps, null)(HomeContent); -------------------------------------------------------------------------------- /src/containers/Home/HomeContent/HomeContent.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/shared.scss'; 2 | 3 | .home-content { 4 | margin-left: $sidebar-left-width; 5 | padding-left: 8px; 6 | display: grid; 7 | grid: auto / auto; 8 | justify-content: center; 9 | } 10 | 11 | @media all and (min-width: 562px) { 12 | .responsive-video-grid-container { 13 | max-width: 600px; 14 | } 15 | } 16 | 17 | @media all and (min-width: 908px) { 18 | .responsive-video-grid-container { 19 | max-width: 848px; 20 | } 21 | } 22 | 23 | @media all and (min-width: 1254px) { 24 | .responsive-video-grid-container { 25 | max-width: 1096px; 26 | } 27 | } 28 | 29 | @media all and (min-width: 1600px) { 30 | .responsive-video-grid-container { 31 | max-width: 1344px; /*320 (video preview width) * 4 + 16 (margin) * 4*/ 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/containers/Home/HomeContent/__tests__/HomeContent.unit.test.js: -------------------------------------------------------------------------------- 1 | import {shallow} from 'enzyme'; 2 | import {HomeContent} from '../HomeContent'; 3 | import React from 'react'; 4 | 5 | describe('HomeContent', () => { 6 | test('renders', () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/containers/Home/HomeContent/__tests__/__snapshots__/HomeContent.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HomeContent renders 1`] = ` 4 |
7 |
10 | 14 | 18 | 19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /src/containers/Search/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Search.scss'; 3 | import {getYoutubeLibraryLoaded} from '../../store/reducers/api'; 4 | import {getSearchNextPageToken, getSearchResults} from '../../store/reducers/search'; 5 | import * as searchActions from '../../store/actions/search'; 6 | import {bindActionCreators} from 'redux'; 7 | import {connect} from 'react-redux'; 8 | import {getSearchParam} from '../../services/url'; 9 | import {VideoList} from '../../components/VideoList/VideoList'; 10 | import {withRouter} from 'react-router-dom'; 11 | 12 | class Search extends React.Component { 13 | render() { 14 | return (); 18 | } 19 | 20 | getSearchQuery() { 21 | return getSearchParam(this.props.location, 'search_query'); 22 | } 23 | 24 | componentDidMount() { 25 | if (!this.getSearchQuery()) { 26 | // redirect to home component if search query is not there 27 | this.props.history.push('/'); 28 | } 29 | this.searchForVideos(); 30 | } 31 | 32 | componentDidUpdate(prevProps) { 33 | if (prevProps.youtubeApiLoaded !== this.props.youtubeApiLoaded) { 34 | this.searchForVideos(); 35 | } 36 | } 37 | 38 | searchForVideos() { 39 | const searchQuery = this.getSearchQuery(); 40 | if (this.props.youtubeApiLoaded) { 41 | this.props.searchForVideos(searchQuery); 42 | } 43 | } 44 | 45 | bottomReachedCallback = () => { 46 | if(this.props.nextPageToken) { 47 | this.props.searchForVideos(this.getSearchQuery(), this.props.nextPageToken, 25); 48 | } 49 | }; 50 | 51 | 52 | } 53 | 54 | function mapDispatchToProps(dispatch) { 55 | const searchForVideos = searchActions.forVideos.request; 56 | return bindActionCreators({searchForVideos}, dispatch); 57 | } 58 | 59 | function mapStateToProps(state, props) { 60 | return { 61 | youtubeApiLoaded: getYoutubeLibraryLoaded(state), 62 | searchResults: getSearchResults(state, props.location.search), 63 | nextPageToken: getSearchNextPageToken(state, props.location.search), 64 | }; 65 | } 66 | 67 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Search)); 68 | 69 | -------------------------------------------------------------------------------- /src/containers/Search/Search.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/src/containers/Search/Search.scss -------------------------------------------------------------------------------- /src/containers/SideBar/SideBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SideBarItem from './SideBarItem/SideBarItem'; 3 | import {Menu, Divider} from 'semantic-ui-react'; 4 | import './SideBar.scss'; 5 | import {SideBarHeader} from './SideBarHeader/SideBarHeader'; 6 | import {Subscriptions} from './Subscriptions/Subscriptions'; 7 | import {SideBarFooter} from './SideBarFooter/SideBarFooter'; 8 | 9 | export class SideBar extends React.Component { 10 | render() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBar.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/shared.scss'; 2 | 3 | $sidebar-hover-color: #ebebeb; 4 | .ui.menu.fixed.side-nav { 5 | background-color: #f5f5f5; 6 | margin-top: $header-nav-height; 7 | overflow-y: auto; 8 | padding-bottom: $header-nav-height; 9 | 10 | .sidebar-item:hover { 11 | background: $sidebar-hover-color; 12 | } 13 | 14 | .subscription:hover { 15 | background: #ebebeb; 16 | cursor: pointer; 17 | } 18 | } 19 | 20 | .side-nav.ui.vertical.menu { 21 | width: $sidebar-left-width; 22 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarFooter/SideBarFooter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './SideBarFooter.scss' 3 | 4 | export function SideBarFooter() { 5 | return ( 6 | 7 |
8 |
About Press Copyright
9 |
Creators Advertise
10 |
Developers +MyTube
11 |
Legal
12 |
13 |
14 |
Terms Privacy
15 |
Policy & Safety
16 |
Test new features
17 |
18 |
19 |
All prices include VAT
20 |
21 |
22 |
© Productioncoder.com - A Youtube clone for educational purposes under fair use.
23 |
24 |
25 | ); 26 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarFooter/SideBarFooter.scss: -------------------------------------------------------------------------------- 1 | .footer-block { 2 | padding-bottom: 10px; 3 | padding-left: 16px; 4 | color: #918888; 5 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarFooter/__tests__/SideBarFooter.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {SideBarFooter} from '../SideBarFooter'; 3 | import {shallow} from 'enzyme'; 4 | 5 | describe('SideBarFooter', () => { 6 | test('renders', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | }); -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarFooter/__tests__/__snapshots__/SideBarFooter.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SideBarFooter renders 1`] = ` 4 | 5 |
8 |
9 | About Press Copyright 10 |
11 |
12 | Creators Advertise 13 |
14 |
15 | Developers +MyTube 16 |
17 |
18 | Legal 19 |
20 |
21 |
24 |
25 | Terms Privacy 26 |
27 |
28 | Policy & Safety 29 |
30 |
31 | Test new features 32 |
33 |
34 |
37 |
38 | All prices include VAT 39 |
40 |
41 |
44 |
45 | © Productioncoder.com - A Youtube clone for educational purposes under fair use. 46 |
47 |
48 |
49 | `; 50 | -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarHeader/SideBarHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Menu} from 'semantic-ui-react'; 3 | import './SideBarHeader.scss'; 4 | 5 | export function SideBarHeader(props) { 6 | const heading = props.title ? props.title.toUpperCase() : ''; 7 | return ( 8 | 9 | {heading} 10 | 11 | ); 12 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarHeader/SideBarHeader.scss: -------------------------------------------------------------------------------- 1 | .side-bar-header { 2 | color: #6d6d6d; 3 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarHeader/__tests__/SideBarHeader.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {SideBarHeader} from '../SideBarHeader'; 4 | 5 | describe('SideBarHeader', () => { 6 | test('renders without title', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | test('renders with empty title', () => { 11 | const wrapper = shallow(); 12 | expect(wrapper).toMatchSnapshot(); 13 | }); 14 | test('renders with title', () => { 15 | const wrapper = shallow(); 16 | expect(wrapper).toMatchSnapshot(); 17 | }); 18 | }); -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarHeader/__tests__/__snapshots__/SideBarHeader.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SideBarHeader renders with empty title 1`] = ` 4 | 5 | 8 | 9 | `; 10 | 11 | exports[`SideBarHeader renders with title 1`] = ` 12 | 13 | 16 | JUST A TITLE 17 | 18 | 19 | `; 20 | 21 | exports[`SideBarHeader renders without title 1`] = ` 22 | 23 | 26 | 27 | `; 28 | -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarItem/SideBarItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Icon, Menu} from "semantic-ui-react"; 3 | import './SideBarItem.scss'; 4 | import {Link, withRouter} from 'react-router-dom'; 5 | 6 | export class SideBarItem extends React.Component { 7 | render() { 8 | // React will ignore custom boolean attributes, therefore we pass a string 9 | // we use this attribute in our SCSS for styling 10 | const highlight = this.shouldBeHighlighted() ? 'highlight-item' : null; 11 | return ( 12 | 13 | 14 |
15 | 16 | {this.props.label} 17 |
18 |
19 | 20 | ); 21 | } 22 | 23 | shouldBeHighlighted() { 24 | const {pathname} = this.props.location; 25 | if (this.props.path === '/') { 26 | return pathname === this.props.path; 27 | } 28 | return pathname.includes(this.props.path); 29 | } 30 | } 31 | 32 | export default withRouter(SideBarItem); -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarItem/SideBarItem.scss: -------------------------------------------------------------------------------- 1 | .sidebar-item { 2 | background: #f5f5f5; 3 | span { 4 | i.icon { 5 | margin-right: 20px; 6 | color: #888888; 7 | } 8 | } 9 | 10 | &.highlight-item { 11 | span { 12 | font-weight: 600; 13 | } 14 | 15 | i.icon { 16 | color: #ff0002; 17 | } 18 | } 19 | } 20 | 21 | 22 | .sidebar-item-alignment-container { 23 | display: flex; 24 | align-items: center; 25 | } -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarItem/__tests__/SideBarItem.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {SideBarItem} from '../SideBarItem'; 4 | 5 | const location = { 6 | pathname: '/feed/trending', 7 | }; 8 | 9 | describe('SideBarItem', () => { 10 | test('Renders SideBarItem without path', () => { 11 | const wrapper = shallow( 12 | 13 | ); 14 | expect(wrapper).toMatchSnapshot(); 15 | }); 16 | 17 | test('Renders highlighted SideBarItem', () => { 18 | const wrapper = shallow( 19 | 20 | ); 21 | expect(wrapper).toMatchSnapshot(); 22 | }); 23 | 24 | test('Render non-highlighted SideBarItem', () => { 25 | const wrapper = shallow( 26 | 27 | ); 28 | expect(wrapper).toMatchSnapshot(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/containers/SideBar/SideBarItem/__tests__/__snapshots__/SideBarItem.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SideBarItem Render non-highlighted SideBarItem 1`] = ` 4 | 11 | 14 |
17 | 18 | 23 | 24 | 25 | 26 | Trending 27 | 28 |
29 |
30 | 31 | `; 32 | 33 | exports[`SideBarItem Renders SideBarItem without path 1`] = ` 34 | 41 | 44 |
47 | 48 | 52 | 53 | 54 | 55 |
56 |
57 | 58 | `; 59 | 60 | exports[`SideBarItem Renders highlighted SideBarItem 1`] = ` 61 | 68 | 71 |
74 | 75 | 80 | 81 | 82 | 83 | Trending 84 | 85 |
86 |
87 | 88 | `; 89 | -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/Subscription/Subscription.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Icon, Image, Menu} from "semantic-ui-react"; 3 | import './Subscription.scss'; 4 | 5 | export function Subscription(props) { 6 | 7 | let rightElement = null; 8 | const {broadcasting, amountNewVideos} = props; 9 | if (broadcasting) { 10 | rightElement = ; 11 | } else if (amountNewVideos) { 12 | rightElement = {amountNewVideos}; 13 | } 14 | 15 | return ( 16 | 17 |
18 |
19 | 20 | {props.label} 21 |
22 | {rightElement} 23 |
24 |
25 | ); 26 | } -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/Subscription/Subscription.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/shared.scss'; 2 | 3 | .subscription { 4 | width: 100%; 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | 9 | i.icon { 10 | color: $red; 11 | } 12 | 13 | .new-videos-count { 14 | color: $grey; 15 | } 16 | } -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/Subscription/__tests__/Subscription.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {Subscription} from '../Subscription'; 4 | 5 | describe('Subscription', () => { 6 | test('renders empty subscription', () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | test('renders broadcasting subscription', () => { 14 | const wrapper = shallow( 15 | 16 | ); 17 | expect(wrapper).toMatchSnapshot(); 18 | }); 19 | 20 | test('renders non-broadcasting subscription with new videos', () => { 21 | const wrapper = shallow( 22 | 23 | ); 24 | expect(wrapper).toMatchSnapshot(); 25 | }); 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/Subscription/__tests__/__snapshots__/Subscription.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Subscription renders broadcasting subscription 1`] = ` 4 | 5 |
8 |
9 | 15 | 16 | Productioncoder 17 | 18 |
19 | 23 |
24 |
25 | `; 26 | 27 | exports[`Subscription renders empty subscription 1`] = ` 28 | 29 |
32 |
33 | 39 | 40 |
41 |
42 |
43 | `; 44 | 45 | exports[`Subscription renders non-broadcasting subscription with new videos 1`] = ` 46 | 47 |
50 |
51 | 57 | 58 | Productioncoder 59 | 60 |
61 | 64 | 4 65 | 66 |
67 |
68 | `; 69 | -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/Subscriptions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Subscription} from "./Subscription/Subscription"; 3 | import {Divider} from "semantic-ui-react"; 4 | import {SideBarHeader} from '../SideBarHeader/SideBarHeader'; 5 | 6 | export class Subscriptions extends React.Component { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | } -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/__tests__/Subscriptions.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {Subscriptions} from '../Subscriptions'; 4 | 5 | describe('Subscriptions', () => { 6 | test('renders', () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/containers/SideBar/Subscriptions/__tests__/__snapshots__/Subscriptions.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Subscriptions renders 1`] = ` 4 | 5 | 8 | 12 | 16 | 20 | 24 | 28 | 29 | 30 | `; 31 | -------------------------------------------------------------------------------- /src/containers/SideBar/__tests__/SideBar.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {SideBar} from '../SideBar'; 4 | 5 | describe('SideBar', () => { 6 | test('renders', () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/containers/SideBar/__tests__/__snapshots__/SideBar.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SideBar renders 1`] = ` 4 | 11 | 16 | 21 | 25 | 26 | 29 | 33 | 37 | 41 | 42 | 43 | 46 | 50 | 51 | 55 | 59 | 63 | 64 | 65 | 66 | `; 67 | -------------------------------------------------------------------------------- /src/containers/Trending/Trending.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {bindActionCreators} from 'redux'; 3 | import {connect} from 'react-redux'; 4 | import * as videoActions from "../../store/actions/video"; 5 | import { 6 | allMostPopularVideosLoaded, 7 | getMostPopularVideos, 8 | getMostPopularVideosNextPageToken 9 | } from '../../store/reducers/videos'; 10 | import {getYoutubeLibraryLoaded} from '../../store/reducers/api'; 11 | import {VideoList} from '../../components/VideoList/VideoList'; 12 | 13 | class Trending extends React.Component { 14 | componentDidMount() { 15 | this.fetchTrendingVideos(); 16 | } 17 | 18 | componentDidUpdate(prevProps) { 19 | if (prevProps.youtubeLibraryLoaded !== this.props.youtubeLibraryLoaded) { 20 | this.fetchTrendingVideos(); 21 | } 22 | } 23 | 24 | render() { 25 | const loaderActive = this.shouldShowLoader(); 26 | 27 | return (); 31 | } 32 | 33 | 34 | fetchMoreVideos = () => { 35 | const {nextPageToken} = this.props; 36 | if (this.props.youtubeLibraryLoaded && nextPageToken) { 37 | this.props.fetchMostPopularVideos(12, true, nextPageToken); 38 | } 39 | }; 40 | 41 | fetchTrendingVideos() { 42 | if (this.props.youtubeLibraryLoaded) { 43 | this.props.fetchMostPopularVideos(20, true); 44 | } 45 | } 46 | 47 | shouldShowLoader() { 48 | return !this.props.allMostPopularVideosLoaded; 49 | } 50 | } 51 | 52 | function mapStateToProps(state) { 53 | return { 54 | videos: getMostPopularVideos(state), 55 | youtubeLibraryLoaded: getYoutubeLibraryLoaded(state), 56 | allMostPopularVideosLoaded: allMostPopularVideosLoaded(state), 57 | nextPageToken: getMostPopularVideosNextPageToken(state), 58 | }; 59 | } 60 | 61 | function mapDispatchToProps(dispatch) { 62 | const fetchMostPopularVideos = videoActions.mostPopular.request; 63 | return bindActionCreators({fetchMostPopularVideos}, dispatch); 64 | } 65 | 66 | export default connect(mapStateToProps, mapDispatchToProps)(Trending); -------------------------------------------------------------------------------- /src/containers/Watch/Watch.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {bindActionCreators} from 'redux'; 3 | import * as watchActions from '../../store/actions/watch'; 4 | import {withRouter} from 'react-router-dom'; 5 | import {connect} from 'react-redux'; 6 | import {getYoutubeLibraryLoaded} from '../../store/reducers/api'; 7 | import WatchContent from './WatchContent/WatchContent'; 8 | import {getSearchParam} from '../../services/url'; 9 | import {getChannelId} from '../../store/reducers/videos'; 10 | import {getCommentNextPageToken} from '../../store/reducers/comments'; 11 | import * as commentActions from '../../store/actions/comment'; 12 | 13 | 14 | export class Watch extends React.Component { 15 | render() { 16 | const videoId = this.getVideoId(); 17 | return ( 18 | 20 | ); 21 | } 22 | 23 | componentDidMount() { 24 | if (this.props.youtubeLibraryLoaded) { 25 | this.fetchWatchContent(); 26 | } 27 | } 28 | 29 | componentDidUpdate(prevProps) { 30 | if (this.props.youtubeLibraryLoaded !== prevProps.youtubeLibraryLoaded) { 31 | this.fetchWatchContent(); 32 | } 33 | } 34 | 35 | getVideoId() { 36 | return getSearchParam(this.props.location, 'v'); 37 | } 38 | 39 | fetchWatchContent() { 40 | const videoId = this.getVideoId(); 41 | if (!videoId) { 42 | this.props.history.push('/'); 43 | } 44 | this.props.fetchWatchDetails(videoId, this.props.channelId); 45 | } 46 | 47 | fetchMoreComments = () => { 48 | if (this.props.nextPageToken) { 49 | this.props.fetchCommentThread(this.getVideoId(), this.props.nextPageToken); 50 | } 51 | }; 52 | } 53 | 54 | function mapStateToProps(state, props) { 55 | return { 56 | youtubeLibraryLoaded: getYoutubeLibraryLoaded(state), 57 | channelId: getChannelId(state, props.location, 'v'), 58 | nextPageToken: getCommentNextPageToken(state, props.location), 59 | }; 60 | } 61 | 62 | function mapDispatchToProps(dispatch) { 63 | const fetchWatchDetails = watchActions.details.request; 64 | const fetchCommentThread = commentActions.thread.request; 65 | return bindActionCreators({fetchWatchDetails, fetchCommentThread}, dispatch); 66 | } 67 | 68 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Watch)); -------------------------------------------------------------------------------- /src/containers/Watch/WatchContent/WatchContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Video} from '../../../components/Video/Video'; 3 | import {VideoMetadata} from '../../../components/VideoMetadata/VideoMetadata'; 4 | import {VideoInfoBox} from '../../../components/VideoInfoBox/VideoInfoBox'; 5 | import {Comments} from '../../Comments/Comments'; 6 | import {RelatedVideos} from '../../../components/RelatedVideos/RelatedVideos'; 7 | import './WatchContent.scss'; 8 | import {getAmountComments, getRelatedVideos, getVideoById} from '../../../store/reducers/videos'; 9 | import {connect} from 'react-redux'; 10 | import {getChannel} from '../../../store/reducers/channels'; 11 | import {getCommentsForVideo} from '../../../store/reducers/comments'; 12 | import {InfiniteScroll} from '../../../components/InfiniteScroll/InfiniteScroll'; 13 | 14 | class WatchContent extends React.Component { 15 | render() { 16 | if (!this.props.videoId) { 17 | return
18 | } 19 | return ( 20 | 21 |
22 |
28 |
29 | ); 30 | } 31 | shouldShowLoader() { 32 | return !!this.props.nextPageToken; 33 | } 34 | } 35 | 36 | function mapStateToProps(state, props) { 37 | return { 38 | relatedVideos: getRelatedVideos(state, props.videoId), 39 | video: getVideoById(state, props.videoId), 40 | channel: getChannel(state, props.channelId), 41 | comments: getCommentsForVideo(state, props.videoId), 42 | amountComments: getAmountComments(state, props.videoId) 43 | } 44 | } 45 | 46 | export default connect(mapStateToProps, null)(WatchContent); -------------------------------------------------------------------------------- /src/containers/Watch/WatchContent/WatchContent.scss: -------------------------------------------------------------------------------- 1 | .watch-grid { 2 | display: grid; 3 | grid-template: auto auto auto 1fr / minmax(0, 1280px) 402px; 4 | justify-content: center; 5 | padding-top: 24px; 6 | column-gap: 24px; 7 | grid-row-gap: 8px; 8 | 9 | .video { 10 | grid-column: 1 / 2; 11 | grid-row: 1 / 2; 12 | } 13 | .metadata { 14 | grid-column: 1 / 2; 15 | grid-row: 2 / 3; 16 | } 17 | .video-info-box { 18 | grid-column: 1 / 2; 19 | grid-row: 3 / 4; 20 | } 21 | .related-videos { 22 | grid-column: 2 / 3; 23 | grid-row: 1 / span 4; 24 | } 25 | .comments { 26 | grid-column: 1 / 2; 27 | grid-row: 4 / 5; 28 | } 29 | } 30 | 31 | // 1280px (max-width of video column) + 402px (width of side bar) + 3 * 24px (empty space on the left, right and between the two columns) 32 | @media (max-width: 1754px) { 33 | .watch-grid { 34 | padding-left: 24px; 35 | padding-right: 24px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/containers/Watch/__tests__/Watch.unit.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {Watch} from '../Watch'; 4 | 5 | describe('Watch', () => { 6 | test('renders', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | }); -------------------------------------------------------------------------------- /src/containers/Watch/__tests__/__snapshots__/Watch.unit.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Watch renders 1`] = ` 4 | 8 | `; 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import registerServiceWorker from './registerServiceWorker'; 5 | import 'semantic-ui-css/semantic.min.css'; 6 | import { Provider } from 'react-redux'; 7 | import {BrowserRouter} from 'react-router-dom'; 8 | import {configureStore} from './store/configureStore'; 9 | 10 | const store = configureStore(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , document.getElementById('root')); 18 | registerServiceWorker(); -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/services/date/__tests__/date-format.parse.unit.test.js: -------------------------------------------------------------------------------- 1 | import {parseISO8601TimePattern} from '../date-format'; 2 | 3 | describe('date-format ISO8601', () => { 4 | test('parse 4 seconds ISO8601 video duration string ', () => { 5 | expect(parseISO8601TimePattern('PT4S')).toEqual({years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 4}); 6 | }); 7 | 8 | test('parse 13 seconds ISO8601 video duration string', () => { 9 | expect(parseISO8601TimePattern('PT13S')).toEqual({years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 13}); 10 | }); 11 | 12 | test('parse 01:00 min ISO8601 video duration string', () => { 13 | expect(parseISO8601TimePattern('PT1M')).toEqual({years: 0, months: 0, days: 0, hours: 0, minutes: 1, seconds: 0}); 14 | }); 15 | 16 | test('parse 1:31 min ISO8601 video duration string', () => { 17 | expect(parseISO8601TimePattern('PT1M31S')).toEqual({years: 0, months: 0, days: 0, hours: 0, minutes: 1, seconds: 31}); 18 | }); 19 | 20 | test('parse 10:10 min ISO8601 video duration string', () => { 21 | expect(parseISO8601TimePattern('PT10M10S')).toEqual({years: 0, months: 0, days: 0, hours: 0, minutes: 10, seconds: 10}); 22 | }); 23 | 24 | test('parse 03:06:15 hours ISO8601 video duration string', () => { 25 | expect(parseISO8601TimePattern('PT3H6M15S')).toEqual({years: 0, months: 0, days: 0, hours: 3, minutes: 6, seconds: 15}); 26 | }); 27 | 28 | test('parse 13:30:47 hours ISO8601 video duration string', () => { 29 | expect(parseISO8601TimePattern('PT13H30M47S')).toEqual({years: 0, months: 0, days: 0, hours: 13, minutes: 30, seconds: 47}); 30 | }); 31 | 32 | test('parse 13:30:47 hours ISO8601 video duration string', () => { 33 | expect(parseISO8601TimePattern('P1DT25M5S')).toEqual({years: 0, months: 0, days: 1, hours: 0, minutes: 25, seconds: 5}); 34 | }); 35 | }); -------------------------------------------------------------------------------- /src/services/date/__tests__/date-format.videoDuration.unit.test.js: -------------------------------------------------------------------------------- 1 | import {getVideoDurationString} from '../date-format'; 2 | 3 | describe('services/date-format getVideoDurationString()', () => { 4 | test('getVideoDurationString() formats 4s video', () => { 5 | expect(getVideoDurationString('PT4S')).toEqual('0:04'); 6 | }); 7 | 8 | test('getVideoDurationString() formats 13s video', () => { 9 | expect(getVideoDurationString('PT13S')).toEqual('0:13'); 10 | }); 11 | 12 | test('getVideoDurationString() formats 1min video', () => { 13 | expect(getVideoDurationString('PT1M')).toEqual('1:00'); 14 | }); 15 | 16 | test('getVideoDurationString() formats 01:31 min video', () => { 17 | expect(getVideoDurationString('PT1M31S')).toEqual('1:31'); 18 | }); 19 | 20 | test('getVideoDurationString() formats 10:10 min video', () => { 21 | expect(getVideoDurationString('PT10M10S')).toEqual('10:10'); 22 | }); 23 | 24 | test('getVideoDurationString() formats 3:06:15 hours video', () => { 25 | expect(getVideoDurationString('PT3H6M15S')).toEqual('3:06:15'); 26 | }); 27 | 28 | test('getVideoDurationString() formats 13:30:47 hours video', () => { 29 | expect(getVideoDurationString('PT13H30M47S')).toEqual('13:30:47'); 30 | }); 31 | 32 | test('getVideoDurationString() formats 01:00:25:05 days video', () => { 33 | expect(getVideoDurationString('P1DT25M5S')).toEqual('24:25:05'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/services/date/date-format.js: -------------------------------------------------------------------------------- 1 | const objMap = ['years', 'months','days', 'hours', 'minutes', 'seconds']; 2 | const numbers = '\\d+(?:[\\.,]\\d{0,3})?'; 3 | const datePattern = `(${numbers}Y)?(${numbers}M)?(${numbers}D)?`; 4 | const timePattern = `T(${numbers}H)?(${numbers}M)?(${numbers}S)?`; 5 | const pattern = new RegExp(`P(?:${datePattern}(?:${timePattern})?)`); 6 | 7 | export function parseISO8601TimePattern(durationString) { 8 | // https://github.com/tolu/ISO8601-duration/blob/master/src/index.js 9 | return durationString.match(pattern).slice(1).reduce((prev, next, idx) => { 10 | prev[objMap[idx]] = parseFloat(next) || 0; 11 | return prev 12 | }, {}); 13 | } 14 | 15 | export function getPublishedAtDateString(iso8601DateString) { 16 | if (!iso8601DateString) { 17 | return ''; 18 | } 19 | const date = new Date(Date.parse(iso8601DateString)); 20 | return date.toDateString(); 21 | } 22 | 23 | export function getVideoDurationString(iso8601DurationString) { 24 | if (!iso8601DurationString || iso8601DurationString === '') { 25 | return ''; 26 | } 27 | 28 | // new Date(Date.parse(...)) doesn't work here 29 | // therefore we are using our regex approach 30 | let {days, hours, minutes, seconds} = parseISO8601TimePattern(iso8601DurationString); 31 | 32 | let secondsString = seconds.toString(); 33 | let minutesString = minutes.toString(); 34 | let accumulatedHours = days * 24 + hours; 35 | 36 | if (seconds < 10) { 37 | secondsString = seconds.toString().padStart(2, '0'); 38 | } 39 | if (minutes < 10 && hours !== 0) { 40 | minutesString = minutesString.toString().padStart(2, '0'); 41 | } 42 | if (!accumulatedHours) { 43 | return [minutesString, secondsString].join(':'); 44 | } else { 45 | return [accumulatedHours, minutesString, secondsString].join(':'); 46 | } 47 | } 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/services/number/__tests__/number-format.unit.test.js: -------------------------------------------------------------------------------- 1 | import {getShortNumberString} from '../number-format'; 2 | 3 | test('getShortNumberString(0)', () => { 4 | expect(getShortNumberString(0)).toEqual('0'); 5 | }); 6 | 7 | test('getShortNumberString(9)', () => { 8 | expect(getShortNumberString(9)).toEqual('9'); 9 | }); 10 | 11 | test('getShortNumberString(52)', () => { 12 | expect(getShortNumberString(52)).toEqual('52'); 13 | }); 14 | 15 | test('getShortNumberString(456)', () => { 16 | expect(getShortNumberString(456)).toEqual('456'); 17 | }); 18 | 19 | test('getShortNumberString(1001)', () => { 20 | expect(getShortNumberString(1001)).toEqual('1.0K'); 21 | }); 22 | 23 | test('getShortNumberString(1099)', () => { 24 | expect(getShortNumberString(1099)).toEqual('1.1K'); 25 | }); 26 | 27 | test('getShortNumberString(5298)', () => { 28 | expect(getShortNumberString(5298)).toEqual('5.3K'); 29 | }); 30 | 31 | test('getShortNumberString(10053)', () => { 32 | expect(getShortNumberString(10053)).toEqual('10.1K'); 33 | }); 34 | 35 | test('getShortNumberString(10100)', () => { 36 | expect(getShortNumberString(10100)).toEqual('10.1K'); 37 | }); 38 | 39 | test('getShortNumberString(10999)', () => { 40 | expect(getShortNumberString(10999)).toEqual('11.0K'); 41 | }); 42 | 43 | test('getShortNumberString(11732)', () => { 44 | expect(getShortNumberString(11732)).toEqual('12K'); 45 | }); 46 | 47 | test('getShortNumberString(100000)', () => { 48 | expect(getShortNumberString(100000)).toEqual('100K'); 49 | }); 50 | 51 | test('getShortNumberString(532000)', () => { 52 | expect(getShortNumberString(532000)).toEqual('532K'); 53 | }); 54 | 55 | test('getShortNumberString(1000000)', () => { 56 | expect(getShortNumberString(1000000)).toEqual('1M'); 57 | }); 58 | 59 | test('getShortNumberString(1230000)', () => { 60 | expect(getShortNumberString(1230000)).toEqual('1.2M'); 61 | }); 62 | 63 | test('getShortNumberString(23000000)', () => { 64 | expect(getShortNumberString(23000000)).toEqual('23M'); 65 | }); 66 | 67 | test('getShortNumberString(872000000)', () => { 68 | expect(getShortNumberString(872000000)).toEqual('872M'); 69 | }); 70 | 71 | test('getShortNumberString(1000000000)', () => { 72 | expect(getShortNumberString(1000000000)).toEqual('1B'); 73 | }); 74 | 75 | test('getShortNumberString(1500000000)', () => { 76 | expect(getShortNumberString(1500000000)).toEqual('1.5B'); 77 | }); 78 | 79 | test('getShortNumberString(20000000000)', () => { 80 | expect(getShortNumberString(20000000000)).toEqual('20B'); 81 | }); 82 | 83 | test('getShortNumberString(387000000000)', () => { 84 | expect(getShortNumberString(387000000000)).toEqual('387B'); 85 | }); 86 | 87 | test('getShortNumberString(1000000000000)', () => { 88 | expect(getShortNumberString(1000000000000)).toEqual('1T'); 89 | }); 90 | 91 | test('getShortNumberString(1800000000000)', () => { 92 | expect(getShortNumberString(1800000000000)).toEqual('1.8T'); 93 | }); -------------------------------------------------------------------------------- /src/services/number/number-format.js: -------------------------------------------------------------------------------- 1 | const UNITS = ['K', 'M', 'B', 'T']; 2 | 3 | // https://stackoverflow.com/a/28608086/2328833 4 | export function getShortNumberString(number) { 5 | const shouldShowDecimalPlace = UNITS.some((element, index) => { 6 | const lowerBound = Math.pow(1000, index + 1); 7 | const upperBound = lowerBound + lowerBound * 10; 8 | return number > lowerBound && number < upperBound 9 | }); 10 | const digits = shouldShowDecimalPlace ? 1 : 0; 11 | for (let i = UNITS.length - 1; i >= 0; i--) { 12 | const decimal = Math.pow(1000, i + 1); 13 | 14 | if (number >= decimal) { 15 | return (number / decimal).toFixed(digits) + UNITS[i]; 16 | } 17 | } 18 | return number.toString(); 19 | } -------------------------------------------------------------------------------- /src/services/url/index.js: -------------------------------------------------------------------------------- 1 | export const getSearchParam = (location, name) => { 2 | if (!location || !location.search) { 3 | return null; 4 | } 5 | const searchParams = new URLSearchParams(location.search); 6 | return searchParams.get(name); 7 | }; -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Enzyme from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import {createSerializer} from 'enzyme-to-json'; 5 | 6 | expect.addSnapshotSerializer(createSerializer({mode: 'deep'})); 7 | 8 | // React 16 Enzyme adapter 9 | Enzyme.configure({adapter: new Adapter()}); -------------------------------------------------------------------------------- /src/store/actions/api.js: -------------------------------------------------------------------------------- 1 | import {createAction} from './index'; 2 | 3 | export const YOUTUBE_LIBRARY_LOADED = 'YOUTUBE_LIBRARY_LOADED'; 4 | export const youtubeLibraryLoaded = createAction.bind(null, YOUTUBE_LIBRARY_LOADED); -------------------------------------------------------------------------------- /src/store/actions/comment.js: -------------------------------------------------------------------------------- 1 | import {createAction, createRequestTypes, FAILURE, REQUEST, SUCCESS} from './index'; 2 | 3 | export const COMMENT_THREAD = createRequestTypes('COMMENT_THREAD'); 4 | export const thread = { 5 | request: (videoId, nextPageToken) => createAction(COMMENT_THREAD[REQUEST], {videoId, nextPageToken}), 6 | success: (response, videoId) => createAction(COMMENT_THREAD[SUCCESS], {response, videoId}), 7 | failure: (response) => createAction(COMMENT_THREAD[FAILURE], {response}), 8 | }; -------------------------------------------------------------------------------- /src/store/actions/index.js: -------------------------------------------------------------------------------- 1 | export const REQUEST = 'REQUEST'; 2 | export const SUCCESS = 'SUCCESS'; 3 | export const FAILURE = 'FAILURE'; 4 | export function createRequestTypes(base) { 5 | if (!base) { 6 | throw new Error('cannot create request type with base = \'\' or base = null'); 7 | } 8 | return [REQUEST, SUCCESS, FAILURE].reduce((acc, type) => { 9 | acc[type] = `${base}_${type}`; 10 | return acc; 11 | }, {}); 12 | } 13 | 14 | export function createAction(type, payload = {}) { 15 | return { 16 | type, 17 | ...payload, 18 | }; 19 | } -------------------------------------------------------------------------------- /src/store/actions/search.js: -------------------------------------------------------------------------------- 1 | import {createAction, createRequestTypes, FAILURE, REQUEST, SUCCESS} from './index'; 2 | 3 | export const SEARCH_FOR_VIDEOS = createRequestTypes('SEARCH_FOR_VIDEOS'); 4 | export const forVideos = { 5 | request: (searchQuery, nextPageToken, amount) => createAction(SEARCH_FOR_VIDEOS[REQUEST], {searchQuery, nextPageToken, amount}), 6 | success: (response, searchQuery) => createAction(SEARCH_FOR_VIDEOS[SUCCESS], {response, searchQuery}), 7 | failure: (response, searchQuery) => createAction(SEARCH_FOR_VIDEOS[FAILURE], {response, searchQuery}), 8 | }; -------------------------------------------------------------------------------- /src/store/actions/video.js: -------------------------------------------------------------------------------- 1 | import {createAction, createRequestTypes, REQUEST, SUCCESS, FAILURE} from './index'; 2 | 3 | export const VIDEO_CATEGORIES = createRequestTypes('VIDEO_CATEGORIES'); 4 | export const categories = { 5 | request: () => createAction(VIDEO_CATEGORIES[REQUEST]), 6 | success: (response) => createAction(VIDEO_CATEGORIES[SUCCESS], {response}), 7 | failure: (response) => createAction(VIDEO_CATEGORIES[FAILURE], {response}), 8 | }; 9 | 10 | export const MOST_POPULAR = createRequestTypes('MOST_POPULAR'); 11 | export const mostPopular = { 12 | request: (amount, loadDescription, nextPageToken) => createAction(MOST_POPULAR[REQUEST], {amount, loadDescription, nextPageToken}), 13 | success: (response) => createAction(MOST_POPULAR[SUCCESS], {response}), 14 | failure: (response) => createAction(MOST_POPULAR[FAILURE], {response}), 15 | }; 16 | 17 | export const MOST_POPULAR_BY_CATEGORY = createRequestTypes('MOST_POPULAR_BY_CATEGORY'); 18 | export const mostPopularByCategory = { 19 | request: (categories) => createAction(MOST_POPULAR_BY_CATEGORY[REQUEST], {categories}), 20 | success: (response, categories) => createAction(MOST_POPULAR_BY_CATEGORY[SUCCESS], {response, categories}), 21 | failure: (response) => createAction(MOST_POPULAR_BY_CATEGORY[FAILURE], response), 22 | }; 23 | -------------------------------------------------------------------------------- /src/store/actions/watch.js: -------------------------------------------------------------------------------- 1 | import {createAction, createRequestTypes, FAILURE, REQUEST, SUCCESS} from './index'; 2 | 3 | export const WATCH_DETAILS = createRequestTypes('WATCH_DETAILS'); 4 | export const details = { 5 | request: (videoId, channelId) => createAction(WATCH_DETAILS[REQUEST], {videoId, channelId}), 6 | success: (response, videoId) => createAction(WATCH_DETAILS[SUCCESS], {response, videoId}), 7 | failure: (response) => createAction(WATCH_DETAILS[FAILURE], {response}), 8 | }; 9 | 10 | export const VIDEO_DETAILS = createRequestTypes('VIDEO_DETAILS'); 11 | export const videoDetails = { 12 | request: () => { 13 | throw Error('not implemented'); 14 | }, 15 | success: (response) => createAction(VIDEO_DETAILS[SUCCESS], {response}), 16 | failure: (response) => createAction(VIDEO_DETAILS[FAILURE], {response}), 17 | }; -------------------------------------------------------------------------------- /src/store/api/youtube-api-response-types.js: -------------------------------------------------------------------------------- 1 | export const VIDEO_LIST_RESPONSE = 'youtube#videoListResponse'; 2 | export const CHANNEL_LIST_RESPONSE = 'youtube#channelListResponse'; 3 | export const SEARCH_LIST_RESPONSE = 'youtube#searchListResponse'; 4 | export const COMMENT_THREAD_LIST_RESPONSE = 'youtube#commentThreadListResponse'; -------------------------------------------------------------------------------- /src/store/api/youtube-api.js: -------------------------------------------------------------------------------- 1 | export function buildVideoCategoriesRequest() { 2 | return buildApiRequest('GET', 3 | '/youtube/v3/videoCategories', 4 | { 5 | 'part': 'snippet', 6 | 'regionCode': 'US' 7 | }, null); 8 | } 9 | 10 | export function buildMostPopularVideosRequest(amount = 12, loadDescription = false, nextPageToken, videoCategoryId = null) { 11 | let fields = 'nextPageToken,prevPageToken,items(contentDetails/duration,id,snippet(channelId,channelTitle,publishedAt,thumbnails/medium,title),statistics/viewCount),pageInfo(totalResults)'; 12 | if (loadDescription) { 13 | fields += ',items/snippet/description'; 14 | } 15 | return buildApiRequest('GET', 16 | '/youtube/v3/videos', 17 | { 18 | part: 'snippet,statistics,contentDetails', 19 | chart: 'mostPopular', 20 | maxResults: amount, 21 | regionCode: 'US', 22 | pageToken: nextPageToken, 23 | fields, 24 | videoCategoryId, 25 | }, null); 26 | } 27 | 28 | export function buildVideoDetailRequest(videoId) { 29 | return buildApiRequest('GET', 30 | '/youtube/v3/videos', 31 | { 32 | part: 'snippet,statistics,contentDetails', 33 | id: videoId, 34 | fields: 'kind,items(contentDetails/duration,id,snippet(channelId,channelTitle,description,publishedAt,thumbnails/medium,title),statistics)' 35 | }, null); 36 | } 37 | 38 | export function buildChannelRequest(channelId) { 39 | return buildApiRequest('GET', 40 | '/youtube/v3/channels', 41 | { 42 | part: 'snippet,statistics', 43 | id: channelId, 44 | fields: 'kind,items(id,snippet(description,thumbnails/medium,title),statistics/subscriberCount)' 45 | }, null); 46 | } 47 | 48 | export function buildCommentThreadRequest(videoId, nextPageToken) { 49 | return buildApiRequest('GET', 50 | '/youtube/v3/commentThreads', 51 | { 52 | part: 'id,snippet', 53 | pageToken: nextPageToken, 54 | videoId, 55 | }, null); 56 | } 57 | 58 | export function buildSearchRequest(query, nextPageToken, amount = 12) { 59 | return buildApiRequest('GET', 60 | '/youtube/v3/search', 61 | { 62 | part: 'id,snippet', 63 | q: query, 64 | type: 'video', 65 | pageToken: nextPageToken, 66 | maxResults: amount, 67 | }, null); 68 | } 69 | 70 | export function buildRelatedVideosRequest(videoId, amountRelatedVideos = 12) { 71 | return buildApiRequest('GET', 72 | '/youtube/v3/search', 73 | { 74 | part: 'snippet', 75 | type: 'video', 76 | maxResults: amountRelatedVideos, 77 | relatedToVideoId: videoId, 78 | }, null); 79 | } 80 | 81 | /* 82 | Util - Youtube API boilerplate code 83 | */ 84 | export function buildApiRequest(requestMethod, path, params, properties) { 85 | params = removeEmptyParams(params); 86 | let request; 87 | if (properties) { 88 | let resource = createResource(properties); 89 | request = window.gapi.client.request({ 90 | 'body': resource, 91 | 'method': requestMethod, 92 | 'path': path, 93 | 'params': params 94 | }); 95 | } else { 96 | request = window.gapi.client.request({ 97 | 'method': requestMethod, 98 | 'path': path, 99 | 'params': params 100 | }); 101 | } 102 | return request; 103 | } 104 | 105 | function removeEmptyParams(params) { 106 | for (var p in params) { 107 | if (!params[p] || params[p] === 'undefined') { 108 | delete params[p]; 109 | } 110 | } 111 | return params; 112 | } 113 | 114 | function createResource(properties) { 115 | var resource = {}; 116 | var normalizedProps = properties; 117 | for (var p in properties) { 118 | var value = properties[p]; 119 | if (p && p.substr(-2, 2) === '[]') { 120 | var adjustedName = p.replace('[]', ''); 121 | if (value) { 122 | normalizedProps[adjustedName] = value.split(','); 123 | } 124 | delete normalizedProps[p]; 125 | } 126 | } 127 | for (var prop in normalizedProps) { 128 | // Leave properties that don't have values out of inserted resource. 129 | if (normalizedProps.hasOwnProperty(prop) && normalizedProps[prop]) { 130 | var propArray = prop.split('.'); 131 | var ref = resource; 132 | for (var pa = 0; pa < propArray.length; pa++) { 133 | var key = propArray[pa]; 134 | if (pa === propArray.length - 1) { 135 | ref[key] = normalizedProps[prop]; 136 | } else { 137 | ref = ref[key] = ref[key] || {}; 138 | } 139 | } 140 | } 141 | } 142 | return resource; 143 | } -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import {applyMiddleware, compose, createStore} from 'redux'; 2 | import reducer from './reducers'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import rootSaga from './sagas'; 5 | 6 | export function configureStore() { 7 | const sagaMiddleware = createSagaMiddleware(); 8 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 9 | const store = createStore(reducer, composeEnhancers( 10 | applyMiddleware(sagaMiddleware) 11 | )); 12 | sagaMiddleware.run(rootSaga); 13 | return store; 14 | } -------------------------------------------------------------------------------- /src/store/reducers/__tests__/api.unit.test.js: -------------------------------------------------------------------------------- 1 | import apiReducer from '../api'; 2 | import {YOUTUBE_LIBRARY_LOADED} from '../../actions/api'; 3 | 4 | const initialState = { 5 | libraryLoaded: false, 6 | }; 7 | 8 | describe('api reducer', () => { 9 | test('test unused action type with default initial state', () => { 10 | const unusedActionType = 'unused-action-type'; 11 | const expectedEndState = {...initialState}; 12 | expect(apiReducer(undefined, {type: unusedActionType})).toEqual(expectedEndState); 13 | }); 14 | 15 | test('test api reducer with YOUTUBE_LIBRARY_LOADED action', () => { 16 | const startState = {...initialState}; 17 | const expectedEndState = { 18 | libraryLoaded: true, 19 | }; 20 | expect(apiReducer(startState, {type: YOUTUBE_LIBRARY_LOADED})).toEqual(expectedEndState); 21 | }); 22 | 23 | test('test api reducer for idempotence with YOUTUBE_LIBRARY_LOADED action and library already loaded', () => { 24 | const startState = { 25 | libraryLoaded: true, 26 | }; 27 | expect(apiReducer(startState, {type: YOUTUBE_LIBRARY_LOADED})).toEqual(startState); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/store/reducers/__tests__/responses/MOST_POPULAR_SUCCESS.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextPageToken": "CAwQAA", 3 | "pageInfo": { 4 | "totalResults": 200 5 | }, 6 | "items": [ 7 | { 8 | "id": "l1yl1Oz30_k", 9 | "snippet": { 10 | "publishedAt": "2018-08-11T19:00:03.000Z", 11 | "channelId": "UCYzPXprvl5Y-Sf0g4vX-m6g", 12 | "title": "Meeting Connor From Detroit In Real Life!", 13 | "thumbnails": { 14 | "medium": { 15 | "url": "https://i.ytimg.com/vi/l1yl1Oz30_k/mqdefault.jpg", 16 | "width": 320, 17 | "height": 180 18 | } 19 | }, 20 | "channelTitle": "jacksepticeye", 21 | "localized": { 22 | "title": "Meeting Connor From Detroit In Real Life!" 23 | } 24 | }, 25 | "contentDetails": { 26 | "duration": "PT19M59S" 27 | }, 28 | "statistics": { 29 | "viewCount": "2559351" 30 | } 31 | }, 32 | { 33 | "id": "n6cJ6pnBxSQ", 34 | "snippet": { 35 | "publishedAt": "2018-08-11T20:58:30.000Z", 36 | "channelId": "UCilwZiBBfI9X6yiZRzWty8Q", 37 | "title": "This Homeless Man had ONE WISH.. And I Made it COME TRUE!!", 38 | "thumbnails": { 39 | "medium": { 40 | "url": "https://i.ytimg.com/vi/n6cJ6pnBxSQ/mqdefault.jpg", 41 | "width": 320, 42 | "height": 180 43 | } 44 | }, 45 | "channelTitle": "FaZe Rug", 46 | "localized": { 47 | "title": "This Homeless Man had ONE WISH.. And I Made it COME TRUE!!" 48 | } 49 | }, 50 | "contentDetails": { 51 | "duration": "PT14M32S" 52 | }, 53 | "statistics": { 54 | "viewCount": "1193038" 55 | } 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /src/store/reducers/__tests__/states/MOST_POPULAR_SUCCESS.json: -------------------------------------------------------------------------------- 1 | { 2 | "mostPopular": { 3 | "items": [ 4 | "l1yl1Oz30_k", 5 | "n6cJ6pnBxSQ" 6 | ], 7 | "totalResults": 200, 8 | "nextPageToken": "CAwQAA" 9 | }, 10 | "byId": { 11 | "l1yl1Oz30_k": { 12 | "id": "l1yl1Oz30_k", 13 | "snippet": { 14 | "publishedAt": "2018-08-11T19:00:03.000Z", 15 | "channelId": "UCYzPXprvl5Y-Sf0g4vX-m6g", 16 | "title": "Meeting Connor From Detroit In Real Life!", 17 | "thumbnails": { 18 | "medium": { 19 | "url": "https://i.ytimg.com/vi/l1yl1Oz30_k/mqdefault.jpg", 20 | "width": 320, 21 | "height": 180 22 | } 23 | }, 24 | "channelTitle": "jacksepticeye", 25 | "localized": { 26 | "title": "Meeting Connor From Detroit In Real Life!" 27 | } 28 | }, 29 | "contentDetails": { 30 | "duration": "PT19M59S" 31 | }, 32 | "statistics": { 33 | "viewCount": "2559351" 34 | } 35 | }, 36 | "n6cJ6pnBxSQ": { 37 | "id": "n6cJ6pnBxSQ", 38 | "snippet": { 39 | "publishedAt": "2018-08-11T20:58:30.000Z", 40 | "channelId": "UCilwZiBBfI9X6yiZRzWty8Q", 41 | "title": "This Homeless Man had ONE WISH.. And I Made it COME TRUE!!", 42 | "thumbnails": { 43 | "medium": { 44 | "url": "https://i.ytimg.com/vi/n6cJ6pnBxSQ/mqdefault.jpg", 45 | "width": 320, 46 | "height": 180 47 | } 48 | }, 49 | "channelTitle": "FaZe Rug", 50 | "localized": { 51 | "title": "This Homeless Man had ONE WISH.. And I Made it COME TRUE!!" 52 | } 53 | }, 54 | "contentDetails": { 55 | "duration": "PT14M32S" 56 | }, 57 | "statistics": { 58 | "viewCount": "1193038" 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/store/reducers/__tests__/videos.unit.test.js: -------------------------------------------------------------------------------- 1 | import videosReducer, {initialState} from '../videos'; 2 | import {MOST_POPULAR } from '../../actions/video'; 3 | import {SUCCESS} from '../../actions'; 4 | import mostPopularResponse from './responses/MOST_POPULAR_SUCCESS'; 5 | import mostPopularSuccessState from './states/MOST_POPULAR_SUCCESS'; 6 | 7 | 8 | describe('videos reducer', () => { 9 | test('test undefined action type and initial state with videos reducer', () => { 10 | const expectedEndState = {...initialState}; 11 | expect(videosReducer(undefined, {type: 'some-unused-type'})).toEqual(expectedEndState); 12 | }); 13 | 14 | test('test MOST_POPULAR_SUCCESS action in video reducer', () => { 15 | const startState = {...initialState}; 16 | const action = { 17 | type: MOST_POPULAR[SUCCESS], 18 | response: mostPopularResponse, 19 | }; 20 | const expectedEndState = { 21 | ...startState, 22 | ...mostPopularSuccessState 23 | }; 24 | expect(videosReducer(startState, action)).toEqual(expectedEndState); 25 | }); 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /src/store/reducers/api.js: -------------------------------------------------------------------------------- 1 | import {YOUTUBE_LIBRARY_LOADED} from '../actions/api'; 2 | 3 | const initialState = { 4 | libraryLoaded: false, 5 | }; 6 | export default function (state = initialState, action) { 7 | switch (action.type) { 8 | case YOUTUBE_LIBRARY_LOADED: 9 | return { 10 | libraryLoaded: true, 11 | }; 12 | default: 13 | return state; 14 | } 15 | } 16 | export const getYoutubeLibraryLoaded = (state) => state.api.libraryLoaded; -------------------------------------------------------------------------------- /src/store/reducers/channels.js: -------------------------------------------------------------------------------- 1 | import {VIDEO_DETAILS, WATCH_DETAILS} from '../actions/watch'; 2 | import {SUCCESS} from '../actions'; 3 | import {CHANNEL_LIST_RESPONSE} from '../api/youtube-api-response-types'; 4 | 5 | const initialState = { 6 | byId: {} 7 | }; 8 | 9 | export default function (state = initialState, action) { 10 | switch (action.type) { 11 | case WATCH_DETAILS[SUCCESS]: 12 | return reduceWatchDetails(action.response, state); 13 | case VIDEO_DETAILS[SUCCESS]: 14 | return reduceVideoDetails(action.response, state); 15 | default: 16 | return state; 17 | } 18 | } 19 | 20 | function reduceWatchDetails(responses, prevState) { 21 | const channelResponse = responses.find(response => response.result.kind === CHANNEL_LIST_RESPONSE); 22 | let channels = {}; 23 | if (channelResponse && channelResponse.result.items) { 24 | // we know that there will only be one item 25 | // because we ask for a channel with a specific id 26 | const channel = channelResponse.result.items[0]; 27 | channels[channel.id] = channel; 28 | } 29 | return { 30 | ...prevState, 31 | byId: { 32 | ...prevState.byId, 33 | ...channels 34 | } 35 | }; 36 | } 37 | 38 | function reduceVideoDetails(responses, prevState) { 39 | const channelResponse = responses.find(response => response.result.kind === CHANNEL_LIST_RESPONSE); 40 | let channelEntry = {}; 41 | if (channelResponse && channelResponse.result.items) { 42 | // we're explicitly asking for a channel with a particular id 43 | // so the response set must either contain 0 items (if a channel with the specified id does not exist) 44 | // or at most one item (i.e. the channel we've been asking for) 45 | const channel = channelResponse.result.items[0]; 46 | channelEntry = { 47 | [channel.id]: channel, 48 | } 49 | } 50 | 51 | return { 52 | ...prevState, 53 | byId: { 54 | ...prevState.byId, 55 | ...channelEntry, 56 | } 57 | }; 58 | } 59 | 60 | /* 61 | * Selectors 62 | * */ 63 | export const getChannel = (state, channelId) => { 64 | if (!channelId) return null; 65 | return state.channels.byId[channelId]; 66 | }; -------------------------------------------------------------------------------- /src/store/reducers/comments.js: -------------------------------------------------------------------------------- 1 | import {SUCCESS} from '../actions'; 2 | import {WATCH_DETAILS} from '../actions/watch'; 3 | import {COMMENT_THREAD_LIST_RESPONSE} from '../api/youtube-api-response-types'; 4 | import {createSelector} from 'reselect'; 5 | import {COMMENT_THREAD} from '../actions/comment'; 6 | import {getSearchParam} from '../../services/url'; 7 | 8 | const initialState = { 9 | byVideo: {}, 10 | byId: {}, 11 | }; 12 | export default function (state = initialState, action) { 13 | switch (action.type) { 14 | case COMMENT_THREAD[SUCCESS]: 15 | return reduceCommentThread(action.response, action.videoId, state); 16 | case WATCH_DETAILS[SUCCESS]: 17 | return reduceWatchDetails(action.response, action.videoId, state); 18 | default: 19 | return state; 20 | } 21 | } 22 | 23 | function reduceWatchDetails(responses, videoId, prevState) { 24 | const commentThreadResponse = responses.find(res => res.result.kind === COMMENT_THREAD_LIST_RESPONSE); 25 | return reduceCommentThread(commentThreadResponse.result, videoId, prevState); 26 | } 27 | 28 | function reduceCommentThread(response, videoId, prevState) { 29 | if (!response) { 30 | return prevState; 31 | } 32 | const newComments = response.items.reduce((acc, item) => { 33 | acc[item.id] = item; 34 | return acc; 35 | }, {}); 36 | 37 | // if we have already fetched some comments for a particular video 38 | // we just append the ids for the new comments 39 | const prevCommentIds = prevState.byVideo[videoId] ? prevState.byVideo[videoId].ids : []; 40 | const commentIds = [...prevCommentIds, ...Object.keys(newComments)]; 41 | 42 | const byVideoComment = { 43 | nextPageToken: response.nextPageToken, 44 | ids: commentIds, 45 | }; 46 | 47 | return { 48 | ...prevState, 49 | byId: { 50 | ...prevState.byId, 51 | ...newComments, 52 | }, 53 | byVideo: { 54 | ...prevState.byVideo, 55 | [videoId]: byVideoComment, 56 | } 57 | }; 58 | } 59 | 60 | /* 61 | * Selectors 62 | */ 63 | const getCommentIdsForVideo = (state, videoId) => { 64 | const comment = state.comments.byVideo[videoId]; 65 | if (comment) { 66 | return comment.ids; 67 | } 68 | return []; 69 | }; 70 | export const getCommentsForVideo = createSelector( 71 | getCommentIdsForVideo, 72 | state => state.comments.byId, 73 | (commentIds, allComments) => { 74 | return commentIds.map(commentId => allComments[commentId]); 75 | } 76 | ); 77 | 78 | const getComment = (state, location) => { 79 | const videoId = getSearchParam(location, 'v'); 80 | return state.comments.byVideo[videoId]; 81 | }; 82 | export const getCommentNextPageToken = createSelector( 83 | getComment, 84 | (comment) => { 85 | return comment ? comment.nextPageToken : null; 86 | } 87 | ); -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | import apiReducer from './api'; 2 | import {combineReducers} from 'redux'; 3 | import videosReducer from './videos' 4 | import channelsReducer from './channels'; 5 | import commentsReducer from './comments'; 6 | import searchReducer from './search'; 7 | 8 | export default combineReducers({ 9 | api: apiReducer, 10 | videos: videosReducer, 11 | channels: channelsReducer, 12 | comments: commentsReducer, 13 | search: searchReducer 14 | }); -------------------------------------------------------------------------------- /src/store/reducers/search.js: -------------------------------------------------------------------------------- 1 | import {SEARCH_FOR_VIDEOS} from '../actions/search'; 2 | import {REQUEST, SUCCESS} from '../actions'; 3 | 4 | export default function (state = {}, action) { 5 | switch (action.type) { 6 | case SEARCH_FOR_VIDEOS[SUCCESS]: 7 | return reduceSearchForVideos(action.response, action.searchQuery, state); 8 | case SEARCH_FOR_VIDEOS[REQUEST]: 9 | // delete the previous search because otherwise our component flickers and shows the 10 | // previous search results before it shows 11 | return action.nextPageToken ? state : {}; 12 | default: 13 | return state; 14 | } 15 | } 16 | 17 | function reduceSearchForVideos(response, searchQuery, prevState) { 18 | let searchResults = response.items.map(item => ({...item, id: item.id.videoId})); 19 | if (prevState.query === searchQuery) { 20 | const prevResults = prevState.results || []; 21 | searchResults = prevResults.concat(searchResults); 22 | } 23 | return { 24 | totalResults: response.pageInfo.totalResults, 25 | nextPageToken: response.nextPageToken, 26 | query: searchQuery, 27 | results: searchResults 28 | }; 29 | } 30 | 31 | /* 32 | Selectors 33 | */ 34 | export const getSearchResults = (state) => state.search.results; 35 | export const getSearchNextPageToken = (state) => state.search.nextPageToken; 36 | -------------------------------------------------------------------------------- /src/store/reducers/videos.js: -------------------------------------------------------------------------------- 1 | import {MOST_POPULAR, MOST_POPULAR_BY_CATEGORY, VIDEO_CATEGORIES} from '../actions/video'; 2 | import {SUCCESS} from '../actions'; 3 | import {createSelector} from 'reselect'; 4 | import {SEARCH_LIST_RESPONSE, VIDEO_LIST_RESPONSE} from '../api/youtube-api-response-types'; 5 | import {VIDEO_DETAILS, WATCH_DETAILS} from '../actions/watch'; 6 | import {getSearchParam} from '../../services/url'; 7 | 8 | export const initialState = { 9 | byId: {}, 10 | mostPopular: {}, 11 | categories: {}, 12 | byCategory: {}, 13 | related: {}, 14 | }; 15 | export default function videos(state = initialState, action) { 16 | switch (action.type) { 17 | case MOST_POPULAR[SUCCESS]: 18 | return reduceFetchMostPopularVideos(action.response, state); 19 | case VIDEO_CATEGORIES[SUCCESS]: 20 | return reduceFetchVideoCategories(action.response, state); 21 | case MOST_POPULAR_BY_CATEGORY[SUCCESS]: 22 | return reduceFetchMostPopularVideosByCategory(action.response, action.categories, state); 23 | case WATCH_DETAILS[SUCCESS]: 24 | return reduceWatchDetails(action.response, state); 25 | case VIDEO_DETAILS[SUCCESS]: 26 | return reduceVideoDetails(action.response, state); 27 | default: 28 | return state; 29 | } 30 | } 31 | 32 | function reduceFetchMostPopularVideos(response, prevState) { 33 | const videoMap = response.items.reduce((accumulator, video) => { 34 | accumulator[video.id] = video; 35 | return accumulator; 36 | }, {}); 37 | 38 | let items = Object.keys(videoMap); 39 | if (response.hasOwnProperty('prevPageToken') && prevState.mostPopular) { 40 | items = [...prevState.mostPopular.items, ...items]; 41 | } 42 | 43 | const mostPopular = { 44 | totalResults: response.pageInfo.totalResults, 45 | nextPageToken: response.nextPageToken, 46 | items, 47 | }; 48 | 49 | return { 50 | ...prevState, 51 | mostPopular, 52 | byId: {...prevState.byId, ...videoMap}, 53 | }; 54 | } 55 | 56 | function reduceFetchVideoCategories(response, prevState) { 57 | const categoryMapping = response.items.reduce((accumulator, category) => { 58 | accumulator[category.id] = category.snippet.title; 59 | return accumulator; 60 | }, {}); 61 | return { 62 | ...prevState, 63 | categories: categoryMapping, 64 | }; 65 | } 66 | 67 | function reduceFetchMostPopularVideosByCategory(responses, categories, prevState) { 68 | let videoMap = {}; 69 | let byCategoryMap = {}; 70 | 71 | responses.forEach((response, index) => { 72 | // ignore answer if there was an error 73 | if (response.status === 400) return; 74 | 75 | const categoryId = categories[index]; 76 | const {byId, byCategory} = groupVideosByIdAndCategory(response.result); 77 | videoMap = {...videoMap, ...byId}; 78 | byCategoryMap[categoryId] = byCategory; 79 | }); 80 | 81 | // compute new state 82 | return { 83 | ...prevState, 84 | byId: {...prevState.byId, ...videoMap}, 85 | byCategory: {...prevState.byCategory, ...byCategoryMap}, 86 | }; 87 | } 88 | 89 | function groupVideosByIdAndCategory(response) { 90 | const videos = response.items; 91 | const byId = {}; 92 | const byCategory = { 93 | totalResults: response.pageInfo.totalResults, 94 | nextPageToken: response.nextPageToken, 95 | items: [], 96 | }; 97 | 98 | videos.forEach((video) => { 99 | byId[video.id] = video; 100 | 101 | const items = byCategory.items; 102 | if(items && items) { 103 | items.push(video.id); 104 | } else { 105 | byCategory.items = [video.id]; 106 | } 107 | }); 108 | 109 | return {byId, byCategory}; 110 | } 111 | 112 | function reduceWatchDetails(responses, prevState) { 113 | const videoDetailResponse = responses.find(r => r.result.kind === VIDEO_LIST_RESPONSE); 114 | // we know that items will only have one element 115 | // because we explicitly asked for a video with a specific id 116 | const video = videoDetailResponse.result.items[0]; 117 | const relatedEntry = reduceRelatedVideosRequest(responses); 118 | 119 | return { 120 | ...prevState, 121 | byId: { 122 | ...prevState.byId, 123 | [video.id]: video 124 | }, 125 | related: { 126 | ...prevState.related, 127 | [video.id]: relatedEntry 128 | } 129 | }; 130 | } 131 | 132 | function reduceRelatedVideosRequest(responses) { 133 | const relatedVideosResponse = responses.find(r => r.result.kind === SEARCH_LIST_RESPONSE); 134 | const {pageInfo, items, nextPageToken} = relatedVideosResponse.result; 135 | const relatedVideoIds = items.map(video => video.id.videoId); 136 | 137 | return { 138 | totalResults: pageInfo.totalResults, 139 | nextPageToken, 140 | items: relatedVideoIds 141 | }; 142 | } 143 | 144 | function reduceVideoDetails(responses, prevState) { 145 | const videoResponses = responses.filter(response => response.result.kind === VIDEO_LIST_RESPONSE); 146 | const parsedVideos = videoResponses.reduce((videoMap, response) => { 147 | // we're explicitly asking for a video with a particular id 148 | // so the response set must either contain 0 items (if a video with the id does not exist) 149 | // or at most one item (i.e. the channel we've been asking for) 150 | const video = response.result.items ? response.result.items[0] : null; 151 | if (!video) { 152 | return videoMap; 153 | } 154 | videoMap[video.id] = video; 155 | return videoMap; 156 | }, {}); 157 | 158 | return { 159 | ...prevState, 160 | byId: {...prevState.byId, ...parsedVideos}, 161 | }; 162 | } 163 | 164 | /* function reduceVideoDetails(responses) { 165 | const videoResponses = responses.filter(response => response.result.kind === VIDEO_LIST_RESPONSE); 166 | return videoResponses.reduce((accumulator, response) => { 167 | response.result.items.forEach(video => { 168 | accumulator[video.id] = video; 169 | }); 170 | return accumulator; 171 | }, {}); 172 | } 173 | 174 | function reduceRelatedVideos(responses, videoIds) { 175 | const videoResponses = responses.filter(response => response.result.kind === SEARCH_LIST_RESPONSE); 176 | return videoResponses.reduce((accumulator, response, index) => { 177 | const relatedIds = response.result.items.map(video => video.id.videoId); 178 | accumulator[videoIds[index]] = { 179 | totalResults: response.result.pageInfo.totalResults, 180 | nextPageToken: response.result.nextPageToken, 181 | items: relatedIds 182 | }; 183 | return accumulator; 184 | }, {}); 185 | } */ 186 | 187 | 188 | /* 189 | * Selectors 190 | * */ 191 | const getMostPopular = (state) => state.videos.mostPopular; 192 | export const getMostPopularVideos = createSelector( 193 | (state) => state.videos.byId, 194 | getMostPopular, 195 | (videosById, mostPopular) => { 196 | if (!mostPopular || !mostPopular.items) { 197 | return []; 198 | } 199 | return mostPopular.items.map(videoId => videosById[videoId]); 200 | } 201 | ); 202 | export const getVideoCategoryIds = createSelector( 203 | state => state.videos.categories, 204 | (categories) => { 205 | return Object.keys(categories || {}); 206 | } 207 | ); 208 | 209 | export const getVideosByCategory = createSelector( 210 | state => state.videos.byCategory, 211 | state => state.videos.byId, 212 | state => state.videos.categories, 213 | (videosByCategory, videosById, categories) => { 214 | return Object.keys(videosByCategory || {}).reduce((accumulator, categoryId) => { 215 | const videoIds = videosByCategory[categoryId].items; 216 | const categoryTitle = categories[categoryId]; 217 | accumulator[categoryTitle] = videoIds.map(videoId => videosById[videoId]); 218 | return accumulator; 219 | }, {}); 220 | } 221 | ); 222 | 223 | export const videoCategoriesLoaded = createSelector( 224 | state => state.videos.categories, 225 | (categories) => { 226 | return Object.keys(categories || {}).length !== 0; 227 | } 228 | ); 229 | 230 | export const videosByCategoryLoaded = createSelector( 231 | state => state.videos.byCategory, 232 | (videosByCategory) => { 233 | return Object.keys(videosByCategory || {}).length; 234 | } 235 | ); 236 | 237 | export const getVideoById = (state, videoId) => { 238 | return state.videos.byId[videoId]; 239 | }; 240 | const getRelatedVideoIds = (state, videoId) => { 241 | const related = state.videos.related[videoId]; 242 | return related ? related.items : []; 243 | }; 244 | export const getRelatedVideos = createSelector( 245 | getRelatedVideoIds, 246 | state => state.videos.byId, 247 | (relatedVideoIds, videos) => { 248 | if (relatedVideoIds) { 249 | // filter kicks out null values we might have 250 | return relatedVideoIds.map(videoId => videos[videoId]).filter(video => video); 251 | } 252 | return []; 253 | }); 254 | 255 | export const getChannelId = (state, location, name) => { 256 | const videoId = getSearchParam(location, name); 257 | const video = state.videos.byId[videoId]; 258 | if (video) { 259 | return video.snippet.channelId; 260 | } 261 | return null; 262 | }; 263 | 264 | export const getAmountComments = createSelector( 265 | getVideoById, 266 | (video) => { 267 | if (video) { 268 | return video.statistics.commentCount; 269 | } 270 | return 0; 271 | }); 272 | 273 | export const allMostPopularVideosLoaded = createSelector( 274 | [getMostPopular], 275 | (mostPopular) => { 276 | const amountFetchedItems = mostPopular.items ? mostPopular.items.length : 0; 277 | return amountFetchedItems === mostPopular.totalResults; 278 | } 279 | ); 280 | 281 | export const getMostPopularVideosNextPageToken = createSelector( 282 | [getMostPopular], 283 | (mostPopular) => { 284 | return mostPopular.nextPageToken; 285 | } 286 | ); 287 | 288 | -------------------------------------------------------------------------------- /src/store/sagas/comment.js: -------------------------------------------------------------------------------- 1 | import {fork, take} from 'redux-saga/effects'; 2 | import {REQUEST} from '../actions'; 3 | import * as commentActions from '../actions/comment' 4 | import * as api from '../api/youtube-api'; 5 | import {fetchEntity} from './index'; 6 | 7 | export function* fetchCommentThread(videoId, nextPageToken) { 8 | const request = api.buildCommentThreadRequest.bind(null, videoId, nextPageToken); 9 | yield fetchEntity(request, commentActions.thread, videoId); 10 | } 11 | 12 | /******************************************************************************/ 13 | /******************************* WATCHERS *************************************/ 14 | /******************************************************************************/ 15 | export function* watchCommentThread() { 16 | while(true) { 17 | const {videoId, nextPageToken} = yield take(commentActions.COMMENT_THREAD[REQUEST]); 18 | yield fork(fetchCommentThread, videoId, nextPageToken); 19 | } 20 | } -------------------------------------------------------------------------------- /src/store/sagas/index.js: -------------------------------------------------------------------------------- 1 | import {all, call, put, fork} from 'redux-saga/effects'; 2 | import {watchMostPopularVideos, watchMostPopularVideosByCategory, watchVideoCategories} from './video'; 3 | import {watchWatchDetails} from './watch'; 4 | import {watchCommentThread} from './comment'; 5 | import {watchSearchForVideos} from './search'; 6 | export default function* () { 7 | yield all([ 8 | fork(watchMostPopularVideos), 9 | fork(watchVideoCategories), 10 | fork(watchMostPopularVideosByCategory), 11 | fork(watchWatchDetails), 12 | fork(watchCommentThread), 13 | fork(watchSearchForVideos) 14 | ]); 15 | } 16 | 17 | /* 18 | * entity must have a success, request and failure method 19 | * request is a function that returns a promise when called 20 | * */ 21 | export function* fetchEntity(request, entity, ...args) { 22 | try { 23 | const response = yield call(request); 24 | // we directly return the result object and throw away the headers and the status text here 25 | // if status and headers are needed, then instead of returning response.result, we have to return just response. 26 | yield put(entity.success(response.result, ...args)); 27 | } catch (error) { 28 | yield put(entity.failure(error, ...args)); 29 | } 30 | } 31 | 32 | export function ignoreErrors(fn, ...args) { 33 | return () => { 34 | const ignoreErrorCallback = (response) => response; 35 | return fn(...args).then(ignoreErrorCallback, ignoreErrorCallback); 36 | }; 37 | } -------------------------------------------------------------------------------- /src/store/sagas/search.js: -------------------------------------------------------------------------------- 1 | import * as searchActions from '../actions/search'; 2 | import {REQUEST} from '../actions'; 3 | import {fork, take} from 'redux-saga/effects'; 4 | import * as api from '../api/youtube-api'; 5 | import {fetchEntity} from './index'; 6 | 7 | export function* searchForVideos(searchQuery, nextPageToken, amount) { 8 | const request = api.buildSearchRequest.bind(null, searchQuery, nextPageToken, amount); 9 | yield fetchEntity(request, searchActions.forVideos, searchQuery); 10 | } 11 | 12 | /******************************************************************************/ 13 | /******************************* WATCHERS *************************************/ 14 | /******************************************************************************/ 15 | export function* watchSearchForVideos() { 16 | while (true) { 17 | const {searchQuery, amount, nextPageToken} = yield take(searchActions.SEARCH_FOR_VIDEOS[REQUEST]); 18 | yield fork(searchForVideos, searchQuery, nextPageToken, amount); 19 | } 20 | } -------------------------------------------------------------------------------- /src/store/sagas/video.js: -------------------------------------------------------------------------------- 1 | import {fork, take, takeEvery, call, all, put} from 'redux-saga/effects'; 2 | import * as api from '../api/youtube-api'; 3 | import * as videoActions from '../actions/video'; 4 | import {REQUEST} from '../actions'; 5 | import {fetchEntity, ignoreErrors} from './index'; 6 | 7 | export const fetchVideoCategories = fetchEntity.bind(null, api.buildVideoCategoriesRequest, videoActions.categories); 8 | 9 | 10 | export function* fetchMostPopularVideosByCategory(categories) { 11 | const requests = categories.map(categoryId => { 12 | const wrapper = ignoreErrors(api.buildMostPopularVideosRequest, 12, false, null, categoryId); 13 | return call(wrapper); 14 | }); 15 | try { 16 | const response = yield all(requests); 17 | yield put(videoActions.mostPopularByCategory.success(response, categories)); 18 | } catch (error) { 19 | yield put(videoActions.mostPopularByCategory.failure(error)); 20 | } 21 | } 22 | 23 | export function* fetchMostPopularVideos(amount, loadDescription, nextPageToken) { 24 | const request = api.buildMostPopularVideosRequest.bind(null, amount, loadDescription, nextPageToken); 25 | yield fetchEntity(request, videoActions.mostPopular); 26 | } 27 | 28 | 29 | /******************************************************************************/ 30 | /******************************* WATCHERS *************************************/ 31 | /******************************************************************************/ 32 | export function* watchMostPopularVideos() { 33 | while (true) { 34 | const {amount, loadDescription, nextPageToken} = yield take(videoActions.MOST_POPULAR[REQUEST]); 35 | yield fork(fetchMostPopularVideos, amount, loadDescription, nextPageToken); 36 | } 37 | } 38 | 39 | export function* watchVideoCategories() { 40 | yield takeEvery(videoActions.VIDEO_CATEGORIES[REQUEST], fetchVideoCategories); 41 | } 42 | export function* watchMostPopularVideosByCategory() { 43 | while(true) { 44 | const {categories} = yield take(videoActions.MOST_POPULAR_BY_CATEGORY[REQUEST]); 45 | yield fork(fetchMostPopularVideosByCategory, categories); 46 | } 47 | } -------------------------------------------------------------------------------- /src/store/sagas/watch.js: -------------------------------------------------------------------------------- 1 | import {fork, take, all, put, call} from 'redux-saga/effects'; 2 | import * as watchActions from '../actions/watch'; 3 | import { 4 | buildVideoDetailRequest, 5 | buildRelatedVideosRequest, 6 | buildChannelRequest, 7 | buildCommentThreadRequest 8 | } from '../api/youtube-api'; 9 | import {REQUEST} from '../actions'; 10 | import {SEARCH_LIST_RESPONSE, VIDEO_LIST_RESPONSE} from '../api/youtube-api-response-types'; 11 | 12 | export function* fetchWatchDetails(videoId, channelId) { 13 | let requests = [ 14 | buildVideoDetailRequest.bind(null, videoId), 15 | buildRelatedVideosRequest.bind(null, videoId), 16 | buildCommentThreadRequest.bind(null, videoId) 17 | ]; 18 | 19 | if (channelId) { 20 | requests.push(buildChannelRequest.bind(null, channelId)); 21 | } 22 | 23 | try { 24 | const responses = yield all(requests.map(fn => call(fn))); 25 | yield put(watchActions.details.success(responses, videoId)); 26 | yield call (fetchVideoDetails, responses, channelId === null); 27 | } catch (error) { 28 | yield put(watchActions.details.failure(error)); 29 | } 30 | } 31 | 32 | function* fetchVideoDetails(responses, shouldFetchChannelInfo) { 33 | const searchListResponse = responses.find(response => response.result.kind === SEARCH_LIST_RESPONSE); 34 | const relatedVideoIds = searchListResponse.result.items.map(relatedVideo => relatedVideo.id.videoId); 35 | 36 | const requests = relatedVideoIds.map(relatedVideoId => { 37 | return buildVideoDetailRequest.bind(null, relatedVideoId); 38 | }); 39 | 40 | if (shouldFetchChannelInfo) { 41 | // we have to extract the video's channel id from the video details response 42 | // so we can load additional channel information. 43 | // this is only needed, when a user directly accesses .../watch?v=1234 44 | // because then we only know the video id 45 | const videoDetailResponse = responses.find(response => response.result.kind === VIDEO_LIST_RESPONSE); 46 | const videos = videoDetailResponse.result.items; 47 | if (videos && videos.length) { 48 | requests.push(buildChannelRequest.bind(null, videos[0].snippet.channelId)); 49 | } 50 | } 51 | 52 | try { 53 | const responses = yield all(requests.map(fn => call(fn))); 54 | yield put(watchActions.videoDetails.success(responses)); 55 | } catch (error) { 56 | yield put(watchActions.videoDetails.failure(error)); 57 | } 58 | } 59 | 60 | 61 | /******************************************************************************/ 62 | /******************************* WATCHERS *************************************/ 63 | /******************************************************************************/ 64 | export function* watchWatchDetails() { 65 | while (true) { 66 | const {videoId, channelId} = yield take(watchActions.WATCH_DETAILS[REQUEST]); 67 | yield fork(fetchWatchDetails, videoId, channelId); 68 | } 69 | } -------------------------------------------------------------------------------- /src/styles/_shared.scss: -------------------------------------------------------------------------------- 1 | $header-nav-height: 64px; 2 | $sidebar-left-width: 17rem; 3 | $grey: #888888; 4 | $red: #ff0002; 5 | $text-color-dark: #111111; 6 | $avatar-diameter: 48px; 7 | $avatar-margin: 10px; --------------------------------------------------------------------------------