├── .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 |
5 |
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 |
17 |
18 |
19 |
20 |
21 |
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 |
132 |
133 |
134 |
135 |
136 |
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 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
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 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 | 1535704620846
399 |
400 |
401 | 1535704620846
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
507 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 |
545 | file://$PROJECT_DIR$/src/containers/Watch/Watch.js
546 | 47
547 |
548 |
549 |
550 | file://$PROJECT_DIR$/src/store/reducers/comments.js
551 | 82
552 |
553 |
554 |
555 | file://$PROJECT_DIR$/src/store/reducers/comments.js
556 | 79
557 |
558 |
559 |
560 | file://$PROJECT_DIR$/src/containers/Search/Search.js
561 | 33
562 |
563 |
564 |
565 |
566 |
567 |
568 |
569 |
570 |
571 |
572 |
573 |
574 |
575 |
576 |
577 |
578 |
579 |
580 |
581 |
582 |
583 |
584 |
585 |
586 |
587 |
588 |
589 |
590 |
591 |
592 |
593 |
594 |
595 |
596 |
597 |
598 |
599 |
600 |
601 |
602 |
603 |
604 |
605 |
606 |
607 |
608 |
609 |
610 |
611 |
612 |
613 |
614 |
615 |
616 |
617 |
618 |
619 |
620 |
621 |
622 |
623 |
624 |
625 |
626 |
627 |
628 |
629 |
630 |
631 |
632 |
633 |
634 |
635 |
636 |
637 |
638 |
639 |
640 |
641 |
642 |
643 |
644 |
645 |
646 |
647 |
648 |
649 |
650 |
651 |
652 |
653 |
654 |
655 |
656 |
657 |
658 |
659 |
660 |
661 |
662 |
663 |
664 |
665 |
666 |
667 |
668 |
669 |
670 |
671 |
672 |
673 |
674 |
675 |
676 |
677 |
678 |
679 |
680 |
681 |
682 |
683 |
684 |
685 |
686 |
687 |
688 |
689 |
690 |
691 |
692 |
693 |
694 |
695 |
696 |
697 |
698 |
699 |
700 |
701 |
702 |
703 |
704 |
705 |
706 |
707 |
708 |
709 |
710 |
711 |
712 |
713 |
714 |
715 |
716 |
717 |
718 |
719 |
720 |
721 |
722 |
723 |
724 |
725 |
726 |
727 |
728 |
729 |
730 |
731 |
732 |
733 |
734 |
735 |
736 |
737 |
738 |
739 |
740 |
741 |
742 |
743 |
744 |
745 |
746 |
747 |
748 |
749 |
750 |
751 |
752 |
753 |
754 |
755 |
756 |
757 |
758 |
759 |
760 |
761 |
762 |
763 |
764 |
765 |
766 |
767 |
768 |
769 |
770 |
771 |
772 |
773 |
774 |
775 |
776 |
777 |
778 |
779 |
780 |
781 |
782 |
783 |
784 |
785 |
786 |
787 |
788 |
789 |
790 |
791 |
792 |
793 |
794 |
795 |
796 |
797 |
798 |
799 |
800 |
801 |
802 |
803 |
804 |
805 |
806 |
807 |
808 |
809 |
810 |
811 |
812 |
813 |
814 |
815 |
816 |
817 |
818 |
819 |
820 |
821 |
822 |
823 |
824 |
825 |
826 |
827 |
828 |
829 |
830 |
831 |
832 |
833 |
834 |
835 |
836 |
837 |
838 |
839 |
840 |
841 |
842 |
843 |
844 |
845 |
846 |
847 |
848 |
849 |
850 |
851 |
852 |
853 |
854 |
855 |
856 |
857 |
858 |
859 |
860 |
861 |
862 |
863 |
864 |
865 |
866 |
867 |
868 |
869 |
870 |
871 |
872 |
873 |
874 |
875 |
876 |
877 |
878 |
879 |
880 |
881 |
882 |
883 |
884 |
885 |
886 |
887 |
888 |
889 |
890 |
891 |
892 |
893 |
894 |
895 |
896 |
897 |
898 |
899 |
900 |
901 |
902 |
903 |
904 |
905 |
906 |
907 |
908 |
909 |
910 |
911 |
912 |
913 |
914 |
915 |
916 |
917 |
918 |
919 |
920 |
921 |
--------------------------------------------------------------------------------
/.idea/youtube-react.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 1 About
2 |
3 | This repository is the source code for the epic length [Build Youtube in React](https://productioncoder.com/build-youtube-in-react-part-1/) tutorial series provided by [productioncoder.com](https://productioncoder.com).
4 |
5 |
Please help this repo with a ⭐️ if you find it useful! 😁
6 |
7 | For updates, please follow [@_jgoebel](https://twitter.com/_jgoebel) on Twitter.
8 |
9 | # 2 Demo
10 |
11 | **[Please watch the demo on Youtube](https://www.youtube.com/watch?v=E7wJTI-1dvQ)**
12 |
13 | [](http://www.youtube.com/watch?v=E7wJTI-1dvQ)
14 |
15 | # 3 Screenshots
16 |
17 | UI-wise this application looks **almost exactly like the original Youtube application**
18 |
19 | It uses real data by leveraging the [Youtube Data API v3](https://developers.google.com/youtube/v3/docs/).
20 | 
21 |
22 | 
23 |
24 | 
25 |
26 | # 4 How to run this application
27 |
28 | This application loads information using the [Youtube Data API v3](https://developers.google.com/youtube/v3/docs/).
29 |
30 | To use it, you need to set up a [Youtube Data v3 API key](https://productioncoder.com/build-youtube-in-react-part-19/) and run the project with `npm` or `yarn`.
31 |
32 | **Below, you'll find a step by step explanation**
33 |
34 | ## 4.1. Getting a Youtube Data API key
35 |
36 | 1. Head over to the [Google developers console](https://console.developers.google.com)
37 | 2. Create a new project by clicking on `Select project` drop down right next to the logo. Click the `New Project` button an give it a speaking name.
38 | 3. Select your project by choosing it in the `Select Dropdown` directly next to the logo in the header.
39 | 4. Click the `Enable APIs and Services` button
40 | 5. Search for `youtube data`
41 | 6. Click on the `Youtube Data API v3`
42 | 7. Click the blue enable button
43 | 8. In the dashboard, click `Credentials` on the left sidebar
44 | 9. Click the `Create Credential` button
45 | 10. Which API are you using: `Youtube Data API v3`
46 | 11. Where will you be calling the API from: `Web browser`
47 | 12. What data are you accessing: `Public data`
48 | 13. Click the `What credentials do I need button`
49 | 14. Copy the API key
50 |
51 | ## 4.2. Providinng the API key to your application
52 |
53 | ### 4.2.1 Option 1 - Environment variable (recommended)
54 |
55 | Provide your Youtube Data API key with the `REACT_APP_YT_API_KEY` environment variable.
56 |
57 | Create a `.env.local` file (alread gitignored) with
58 |
59 | ```
60 | touch .env.local
61 | ```
62 |
63 | and then define the `REACT_APP_YT_API_KEY` environment variable which is supposed to hold your `Youtube Data v3 API` key in the `.env.local` file like so:
64 |
65 | ```
66 | REACT_APP_YT_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
67 | ```
68 |
69 | ### 4.2.2 Option 2 - hardcode API key
70 |
71 | As an alternative, you could just hardcode the API key in the `src/App.js` file.
72 |
73 | In general, we'd recommend going with the environment variable approach to **prevent you to accidentially commiting the API key go Git**.
74 |
75 | However, if you do want to hardcode the `Youtube Data API key`, you can head over to the `src/App.js` file and paste your API key in:
76 |
77 | ```
78 | const API_KEY = 'AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
79 | ```
80 |
81 | ## 4.3. Installing dependencies
82 |
83 | To install the dependencies run
84 |
85 | ```
86 | npm install
87 | ```
88 |
89 | With [yarn](https://yarnpkg.com/lang/en/)
90 |
91 | ```
92 | yarn install
93 | ```
94 |
95 | ## 4.4 Running the application
96 |
97 | ### 4.4.1 Running locally with local env vars
98 |
99 | You can run:
100 | ```
101 | npm run dev
102 | ```
103 |
104 | which will source your `.env.local` file and then start then run `npm start`.
105 |
106 | Note that if you change the value of the `.env.local` file, you need to run `npm run dev` again so that the new env var changes are picked up.
107 |
108 | As an alternative, you can manually source the `.env.local` file with
109 |
110 | ```
111 | source .env.local
112 | ```
113 |
114 | and then run
115 |
116 | ```
117 | npm start
118 | ```
119 |
120 | You can also use [yarn](https://yarnpkg.com/lang/en/) to run the application.
121 |
122 | ```
123 | yarn start
124 | ```
125 |
126 | **If you close the terminal, you will need to source the file again. That's why it is recommended to just run `npm run dev` so you don't need to think about it**.
127 |
128 | ### 4.4.2 Running locally with hardcoded Youtube API key
129 |
130 | If you copied and pasted the API key directly into the code, you do not need to source anything and you can just run:
131 |
132 | ```
133 | npm start
134 | ```
135 |
136 | If you are using [yarn](https://yarnpkg.com/lang/en/), you can do
137 |
138 | ```
139 | yarn start
140 | ```
141 |
142 | **Make sure to not commit your file to Git!**
143 |
144 | # 5 Tests
145 |
146 | This project contains an extensive suite of tests and makes use of [Jest](https://jestjs.io/) and [Enzyme](https://github.com/airbnb/enzyme).
147 |
148 | Run all tests by executing.
149 |
150 | ```
151 | npm test
152 | ```
153 |
154 | You can also use [yarn](https://yarnpkg.com/lang/en/) to run the tests.
155 |
156 | ```
157 | yarn test
158 | ```
159 |
160 | # 6 Features
161 |
162 | This application includes the major features of Youtube such as
163 |
164 | - home feed with infinite scroll
165 | - trending videos
166 | - searching for videos
167 | - watching videos
168 | - displaying comments and video details
169 |
170 | # 7 Used technologies
171 |
172 | - [React / create-react-app](https://github.com/facebook/create-react-app)
173 | - [Redux](https://redux.js.org/)
174 | - [Redux-saga](https://redux-saga.js.org/)
175 | - [Redux-reselect](https://github.com/reduxjs/reselect)
176 | - [Jest](https://jestjs.io/)
177 | - [Enzyme](https://airbnb.io/enzyme/)
178 | - [Semantic UI](https://react.semantic-ui.com/)
179 | - CSS Grid / Flexbox
180 |
181 | # 8 Disclaimer
182 |
183 | This project is **solely intended for educational purposes** and is created under **fair use**.
184 |
185 | It is **not intended to create any kind of Youtube competitor**, but to teach advanced concepts in frontend development.
186 |
187 | Just see it a nice educational project that will help you to improve your coding skills.
188 |
--------------------------------------------------------------------------------
/images/youtube-react-home-feed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/images/youtube-react-home-feed.png
--------------------------------------------------------------------------------
/images/youtube-react-watch-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/images/youtube-react-watch-1.png
--------------------------------------------------------------------------------
/images/youtube-react-watch-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/images/youtube-react-watch-2.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "youtube-react",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "javascript-time-ago": "^2.0.13",
7 | "node-sass": "^4.14.1",
8 | "react": "^16.13.1",
9 | "react-dom": "^16.13.1",
10 | "react-linkify": "^1.0.0-alpha",
11 | "react-redux": "^7.2.1",
12 | "react-router-dom": "^5.2.0",
13 | "react-scripts": "3.4.3",
14 | "react-test-renderer": "^16.13.1",
15 | "react-waypoint": "^9.0.3",
16 | "redux": "^4.0.5",
17 | "redux-saga": "^1.1.3",
18 | "reselect": "^4.0.0",
19 | "semantic-ui-css": "^2.4.1",
20 | "semantic-ui-react": "^1.2.0"
21 | },
22 | "scripts": {
23 | "dev": "source .env.local && npm start",
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "devDependencies": {
30 | "enzyme": "^3.11.0",
31 | "enzyme-adapter-react-16": "^1.15.3",
32 | "enzyme-to-json": "^3.5.0"
33 | },
34 | "browserslist": [
35 | ">0.2%",
36 | "not dead",
37 | "not ie <= 11",
38 | "not op_mini all"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | MyTube
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import Home from './containers/Home/Home';
3 | import {AppLayout} from './components/AppLayout/AppLayout';
4 | import {Route, Switch, withRouter} from 'react-router-dom';
5 | import Watch from './containers/Watch/Watch';
6 | import {bindActionCreators} from 'redux';
7 | import {connect} from 'react-redux';
8 | import {youtubeLibraryLoaded} from './store/actions/api';
9 | import Trending from './containers/Trending/Trending';
10 | import Search from './containers/Search/Search';
11 |
12 | const API_KEY = process.env.REACT_APP_YT_API_KEY;
13 |
14 | class App extends Component {
15 | render() {
16 | return (
17 |
18 |
19 |
20 | }/>
21 | }/>
22 |
23 |
24 |
25 | );
26 | }
27 | componentDidMount() {
28 | this.loadYoutubeApi();
29 | }
30 |
31 | loadYoutubeApi() {
32 | const script = document.createElement("script");
33 | script.src = "https://apis.google.com/js/client.js";
34 |
35 | script.onload = () => {
36 | window.gapi.load('client', () => {
37 | window.gapi.client.setApiKey(API_KEY);
38 | window.gapi.client.load('youtube', 'v3', () => {
39 | this.props.youtubeLibraryLoaded();
40 | });
41 | });
42 | };
43 |
44 | document.body.appendChild(script);
45 | }
46 | }
47 |
48 | function mapDispatchToProps(dispatch) {
49 | return bindActionCreators({youtubeLibraryLoaded}, dispatch);
50 | }
51 |
52 | export default withRouter(connect(null, mapDispatchToProps)(App));
--------------------------------------------------------------------------------
/src/__tests__/App.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import App from '../App';
3 | import {shallow} from 'enzyme';
4 |
5 | describe('App', () => {
6 | test('renders', () => {
7 | const wrapper = shallow(
8 |
9 | );
10 | expect(wrapper).toMatchSnapshot();
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/App.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App renders 1`] = `
4 |
5 |
6 |
7 | `;
8 |
--------------------------------------------------------------------------------
/src/assets/images/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jangbl/youtube-react/5f96ca03da96efc549006a425c8dd48fee4e85d2/src/assets/images/logo.jpg
--------------------------------------------------------------------------------
/src/components/AppLayout/AppLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './AppLayout.scss';
3 | import HeaderNav from '../../containers/HeaderNav/HeaderNav';
4 | import ScrollToTop from '../ScrollToTop/ScrollToTop';
5 |
6 | export function AppLayout(props) {
7 | return (
8 |
9 |
10 |
11 | {props.children}
12 |
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/src/components/AppLayout/AppLayout.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/shared.scss';
2 |
3 | .app-layout {
4 | & > *:not(:first-child) {
5 | margin-top: $header-nav-height;
6 | }
7 |
8 | ::-webkit-scrollbar {
9 | width: 6px;
10 | }
11 | }
--------------------------------------------------------------------------------
/src/components/AppLayout/__tests__/AppLayout.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {AppLayout} from '../AppLayout';
4 |
5 | test('renders empty ', () => {
6 | const wrapper = shallow(
7 |
8 | );
9 | expect(wrapper).toMatchSnapshot();
10 | });
11 |
12 | test('renders with one child', () => {
13 | const wrapper = shallow(
14 |
15 | Child 1
16 |
17 | );
18 | expect(wrapper).toMatchSnapshot();
19 | });
20 |
21 | test('renders with children', () => {
22 | const wrapper = shallow(
23 |
24 | Child
25 |
26 |
Child
27 |
Child
28 |
29 |
30 | );
31 | expect(wrapper).toMatchSnapshot();
32 | });
--------------------------------------------------------------------------------
/src/components/AppLayout/__tests__/__snapshots__/AppLayout.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders with children 1`] = `
4 |
5 |
8 |
9 |
10 | Child
11 |
12 |
13 |
14 | Child
15 |
16 |
17 | Child
18 |
19 |
20 |
21 |
22 | `;
23 |
24 | exports[`renders with one child 1`] = `
25 |
26 |
29 |
30 |
31 | Child 1
32 |
33 |
34 |
35 | `;
36 |
37 | exports[`renders empty 1`] = `
38 |
39 |
42 |
43 |
44 |
45 | `;
46 |
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/InfiniteScroll.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Waypoint} from 'react-waypoint';
3 | import {Loader} from 'semantic-ui-react';
4 | import './InfiniteScroll.scss';
5 |
6 | export function InfiniteScroll(props) {
7 | return (
8 |
9 | {props.children}
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/InfiniteScroll.scss:
--------------------------------------------------------------------------------
1 | .loader-container {
2 | padding-bottom: 14px;
3 | }
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/__tests__/InfiniteScroll.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {InfiniteScroll} from '../InfiniteScroll';
4 |
5 | test('renders empty ', () => {
6 | const wrapper = shallow( );
7 | expect(wrapper).toMatchSnapshot();
8 | });
9 |
10 | test('renders with tall child', () => {
11 | const wrapper = shallow(
12 |
13 | );
14 | expect(wrapper).toMatchSnapshot();
15 | });
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/__tests__/__snapshots__/InfiniteScroll.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders with tall child 1`] = `
4 |
5 |
6 |
16 |
19 |
22 |
23 |
24 |
25 | `;
26 |
27 | exports[`renders empty 1`] = `
28 |
29 |
39 |
42 |
45 |
46 |
47 |
48 | `;
49 |
--------------------------------------------------------------------------------
/src/components/Rating/Rating.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './Rating.scss';
3 | import {Icon, Progress} from "semantic-ui-react";
4 | import {getShortNumberString} from '../../services/number/number-format';
5 |
6 | export function Rating(props) {
7 | let rating = null;
8 | let likeCount = props.likeCount !== 0 ? props.likeCount : null;
9 | let dislikeCount = null;
10 |
11 | if(props.likeCount && props.dislikeCount) {
12 | const amountLikes = parseFloat(props.likeCount);
13 | const amountDislikes = parseFloat(props.dislikeCount);
14 | const percentagePositiveRatings = 100.0 * (amountLikes / (amountLikes + amountDislikes));
15 |
16 | // Now that we have calculated the percentage, we bring the numbers into a better readable format
17 | likeCount = getShortNumberString(amountLikes);
18 | dislikeCount = getShortNumberString(amountDislikes);
19 | rating = ;
20 | }
21 | return (
22 |
23 |
24 |
25 | {likeCount}
26 |
27 |
28 |
29 | {dislikeCount}
30 |
31 | {rating}
32 |
33 | );
34 | }
--------------------------------------------------------------------------------
/src/components/Rating/Rating.scss:
--------------------------------------------------------------------------------
1 | .rating {
2 | display: inline-grid;
3 | grid: auto auto / max-content max-content ;
4 | column-gap: 16px;
5 | grid-row-gap: 4px;
6 |
7 | .progress.ui.progress:last-child {
8 | grid-column: 1 / span 2;
9 | grid-row: 2 / 3;
10 |
11 | &.ui.progress:last-child {
12 | margin-bottom: 0;
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/components/Rating/__tests__/Rating.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {Rating} from '../Rating';
4 |
5 | describe('Rating', () => {
6 | test('renders', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | });
11 |
12 |
--------------------------------------------------------------------------------
/src/components/Rating/__tests__/__snapshots__/Rating.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Rating renders 1`] = `
4 |
7 |
8 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 | `;
23 |
--------------------------------------------------------------------------------
/src/components/RelatedVideos/NextUpVideo/NextUpVideo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './NextUpVideo.scss';
3 | import {Checkbox, Divider} from "semantic-ui-react";
4 | import {VideoPreview} from '../../VideoPreview/VideoPreview';
5 |
6 | export function NextUpVideo(props) {
7 | return (
8 |
9 |
10 |
Up next
11 |
12 | Autoplay
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
--------------------------------------------------------------------------------
/src/components/RelatedVideos/NextUpVideo/NextUpVideo.scss:
--------------------------------------------------------------------------------
1 | .next-up-container {
2 | display: flex;
3 | align-items: flex-start;
4 | justify-content: space-between;
5 |
6 | .up-next-toggle {
7 | display: flex;
8 | align-items: center;
9 |
10 | span {
11 | margin-right: 10px;
12 | }
13 | }
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/src/components/RelatedVideos/NextUpVideo/__tests__/NextUpVideo.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {NextUpVideo} from '../NextUpVideo';
4 |
5 | describe('NextUpVideo', () => {
6 | test('renders', () => {
7 | const video = {
8 | id: 'some-id'
9 | };
10 | const wrapper = shallow();
11 | expect(wrapper).toMatchSnapshot();
12 | });
13 | });
--------------------------------------------------------------------------------
/src/components/RelatedVideos/NextUpVideo/__tests__/__snapshots__/NextUpVideo.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`NextUpVideo renders 1`] = `
4 |
5 |
8 |
9 | Up next
10 |
11 |
14 |
15 | Autoplay
16 |
17 |
22 |
23 |
24 |
34 |
35 |
36 | `;
37 |
--------------------------------------------------------------------------------
/src/components/RelatedVideos/RelatedVideos.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {VideoPreview} from '../VideoPreview/VideoPreview';
3 | import './RelatedVideos.scss';
4 | import {NextUpVideo} from './NextUpVideo/NextUpVideo';
5 |
6 | export function RelatedVideos(props) {
7 | if (!props.videos || !props.videos.length) {
8 | return
;
9 | }
10 |
11 | // safe because before we check if the array has at least one element
12 | const nextUpVideo = props.videos[0];
13 | const remainingVideos = props.videos.slice(1);
14 |
15 | const relatedVideosPreviews = remainingVideos.map(relatedVideo => (
16 |
21 | ));
22 |
23 | return (
24 |
25 |
26 | {relatedVideosPreviews}
27 |
28 | );
29 | }
--------------------------------------------------------------------------------
/src/components/RelatedVideos/RelatedVideos.scss:
--------------------------------------------------------------------------------
1 | .related-videos {
2 | a > div {
3 | margin-top: 8px;
4 | }
5 | }
--------------------------------------------------------------------------------
/src/components/RelatedVideos/__tests__/RelatedVideos.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {RelatedVideos} from '../RelatedVideos';
4 |
5 | describe('RelatedVideos', () => {
6 | test('renders', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | });
--------------------------------------------------------------------------------
/src/components/RelatedVideos/__tests__/__snapshots__/RelatedVideos.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`RelatedVideos renders 1`] = `
4 |
7 | `;
8 |
--------------------------------------------------------------------------------
/src/components/ScrollToTop/ScrollToTop.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {withRouter} from 'react-router-dom';
3 |
4 | export class ScrollToTop extends React.Component {
5 | componentDidUpdate(prevProps) {
6 | if (this.props.location !== prevProps.location && window) {
7 | window.scrollTo(0, 0);
8 | }
9 | }
10 | render() {
11 | return this.props.children;
12 | }
13 | }
14 |
15 | export default withRouter(ScrollToTop);
--------------------------------------------------------------------------------
/src/components/Video/Video.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './Video.scss';
3 |
4 | const BASE_EMBED_URL = 'https://www.youtube.com/embed/';
5 |
6 | export function Video(props) {
7 | if(!props.id) {
8 | return null;
9 | }
10 | //const embedUrl = `${BASE_EMBED_URL}${props.id}?autoplay=1`;
11 | const embedUrl = `${BASE_EMBED_URL}${props.id}`;
12 | return (
13 |
20 | );
21 | }
--------------------------------------------------------------------------------
/src/components/Video/Video.scss:
--------------------------------------------------------------------------------
1 | // http://www.mademyday.de/css-height-equals-width-with-pure-css.html
2 | // https://stackoverflow.com/questions/17621181/how-is-padding-top-as-a-percentage-related-to-the-parents-width
3 | // https://alistapart.com/article/creating-intrinsic-ratios-for-video
4 | .video-container {
5 | position: relative;
6 | width: 100%;
7 |
8 | &:before {
9 | content: "";
10 | display: block;
11 | padding-top: 56.25%; // 9 / 16 in percent
12 | }
13 |
14 | .video {
15 | position: absolute;
16 | top: 0;
17 | left: 0;
18 | bottom: 0;
19 | right: 0;
20 |
21 | .video-player {
22 | min-height: 480px;
23 | width: 100%;
24 | height: 100%;
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/src/components/Video/__tests__/Video.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Video} from '../Video';
3 | import {shallow} from 'enzyme';
4 |
5 | describe('Video', () => {
6 | test('renders video component correctly', () => {
7 | const wrapper = shallow(
8 |
9 | );
10 | expect(wrapper).toMatchSnapshot();
11 | });
12 |
13 | test('renders null if id in video component not specified', () => {
14 | const wrapper = shallow(
15 |
16 | );
17 | expect(wrapper).toMatchSnapshot();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/Video/__tests__/__snapshots__/Video.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Video renders null if id in video component not specified 1`] = `""`;
4 |
5 | exports[`Video renders video component correctly 1`] = `
6 |
22 | `;
23 |
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGrid.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './VideoGrid.scss';
3 | import {VideoGridHeader} from "./VideoGridHeader/VideoGridHeader";
4 | import {Divider} from "semantic-ui-react";
5 | import {VideoPreview} from '../VideoPreview/VideoPreview';
6 |
7 | export function VideoGrid(props) {
8 | if (!props.videos || !props.videos.length) {
9 | return
;
10 | }
11 | const gridItems = props.videos.map(video => {
12 | return (
16 | );
17 | });
18 |
19 | const divider = props.hideDivider ? null : ;
20 | return (
21 |
22 |
23 |
24 | {gridItems}
25 |
26 | {divider}
27 |
28 | );
29 | }
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGrid.scss:
--------------------------------------------------------------------------------
1 | .video-grid {
2 | display: flex;
3 | flex-wrap: wrap;
4 |
5 | & > * {
6 | margin-right: 16px;
7 | margin-bottom: 24px;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGridHeader/VideoGridHeader.css:
--------------------------------------------------------------------------------
1 | .video-grid-header {
2 | padding-bottom: 24px;
3 | padding-top: 14px; }
4 | .video-grid-header .title {
5 | font-size: 1.2rem;
6 | font-weight: bold; }
7 |
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGridHeader/VideoGridHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './VideoGridHeader.scss';
3 |
4 | export function VideoGridHeader(props) {
5 | return (
6 |
7 | {props.title}
8 |
9 | );
10 | }
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGridHeader/VideoGridHeader.scss:
--------------------------------------------------------------------------------
1 | .video-grid-header {
2 | padding-bottom: 24px;
3 | padding-top: 14px;
4 | .title {
5 | font-size: 1.5rem;
6 | font-weight: bold;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/VideoGrid/VideoGridHeader/__tests__/VideoGridHeader.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {VideoGridHeader} from '../VideoGridHeader';
4 |
5 | describe('VideoGridHeader', () => {
6 | test('renders without props', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | test('renders with empty string header', () => {
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/components/VideoGrid/VideoGridHeader/__tests__/__snapshots__/VideoGridHeader.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VideoGridHeader renders with empty string header 1`] = `
4 |
7 |
10 |
11 | `;
12 |
13 | exports[`VideoGridHeader renders with title 1`] = `
14 |
17 |
20 | Autos & Vehicles
21 |
22 |
23 | `;
24 |
25 | exports[`VideoGridHeader renders without props 1`] = `
26 |
29 |
32 |
33 | `;
34 |
--------------------------------------------------------------------------------
/src/components/VideoGrid/__tests__/VideoGrid.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {VideoGrid} from '../VideoGrid';
4 |
5 | describe('VideoGrid', () => {
6 | test('renders without props', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | test('renders with title prop', () => {
11 | const wrapper = shallow( );
12 | expect(wrapper).toMatchSnapshot();
13 | });
14 | test('renders without divider', () => {
15 | const wrapper = shallow();
16 | expect(wrapper).toMatchSnapshot();
17 | });
18 | });
--------------------------------------------------------------------------------
/src/components/VideoGrid/__tests__/__snapshots__/VideoGrid.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VideoGrid renders with title prop 1`] = `
`;
4 |
5 | exports[`VideoGrid renders without divider 1`] = `
`;
6 |
7 | exports[`VideoGrid renders without props 1`] = `
`;
8 |
--------------------------------------------------------------------------------
/src/components/VideoInfoBox/VideoInfoBox.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './VideoInfoBox.scss';
3 | import {Image, Button, Divider} from 'semantic-ui-react';
4 | import Linkify from 'react-linkify';
5 | import {getPublishedAtDateString} from '../../services/date/date-format';
6 | import {getShortNumberString} from '../../services/number/number-format';
7 |
8 | export class VideoInfoBox extends React.Component {
9 | constructor(props) {
10 | super(props);
11 | this.state = {
12 | collapsed: true,
13 | };
14 | }
15 |
16 | render() {
17 | if (!this.props.video || !this.props.channel) {
18 | return
;
19 | }
20 |
21 | const descriptionParagraphs = this.getDescriptionParagraphs();
22 | const {descriptionTextClass, buttonTitle} = this.getConfig();
23 | const publishedAtString = getPublishedAtDateString(this.props.video.snippet.publishedAt);
24 |
25 | const {channel} = this.props;
26 | const buttonText = this.getSubscriberButtonText();
27 | const channelThumbnail = channel.snippet.thumbnails.medium.url;
28 | const channelTitle = channel.snippet.title;
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
{channelTitle}
36 |
{publishedAtString}
37 |
38 |
{buttonText}
39 |
40 |
41 | {descriptionParagraphs}
42 |
43 |
{buttonTitle}
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | onToggleCollapseButtonClick = () => {
52 | this.setState((prevState) => {
53 | return {
54 | collapsed: !prevState.collapsed
55 | };
56 | });
57 | };
58 |
59 | getDescriptionParagraphs() {
60 | const videoDescription = this.props.video.snippet ? this.props.video.snippet.description : null;
61 | if (!videoDescription) {
62 | return null;
63 | }
64 | return videoDescription.split('\n').map((paragraph, index) => {paragraph}
);
65 | }
66 |
67 | getSubscriberButtonText() {
68 | const {channel} = this.props;
69 | const parsedSubscriberCount = Number(channel.statistics.subscriberCount);
70 | const subscriberCount = getShortNumberString(parsedSubscriberCount);
71 | return `Subscribe ${subscriberCount}`;
72 | }
73 |
74 | getConfig() {
75 | let descriptionTextClass = 'collapsed';
76 | let buttonTitle = 'Show More';
77 | if (!this.state.collapsed) {
78 | descriptionTextClass = 'expanded';
79 | buttonTitle = 'Show Less';
80 | }
81 | return {
82 | descriptionTextClass,
83 | buttonTitle
84 | };
85 | }
86 | }
--------------------------------------------------------------------------------
/src/components/VideoInfoBox/VideoInfoBox.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/shared.scss';
2 |
3 | .video-info-box {
4 | display: grid;
5 | grid: auto auto / calc(#{$avatar-diameter} + #{$avatar-margin}) auto max-content;
6 | align-items: center;
7 | grid-row-gap: 16px;
8 |
9 | .channel-image {
10 | grid-row: 1 / 2;
11 | grid-column: 1 / 2;
12 | width: $avatar-diameter;
13 | height: $avatar-diameter;
14 | }
15 |
16 | .video-info {
17 | grid-row: 1 / 2;
18 | grid-column: 2 / 3;
19 |
20 | .channel-name {
21 | font-weight: 600;
22 | cursor: pointer;
23 | }
24 | .video-publication-date {
25 | font-size: 13px;
26 | color: #707070;
27 | }
28 | }
29 |
30 | .subscribe {
31 | grid-row: 1 / 2;
32 | grid-column: 3 / 4;
33 | }
34 |
35 | .video-description {
36 |
37 | max-width: 615px;
38 | grid-row: 2 / 3;
39 | grid-column: 2 / 3;
40 |
41 | button {
42 | margin: 8px 0;
43 | }
44 |
45 | p {
46 | line-height: 1.8rem;
47 | margin-bottom: 0;
48 | }
49 |
50 | .collapsed {
51 | max-height: 3.6rem;
52 | overflow-y: hidden;
53 | }
54 |
55 | .expanded {
56 | max-height: none;
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/src/components/VideoInfoBox/__tests__/VideoInfoBox.unit.test.js:
--------------------------------------------------------------------------------
1 | import {VideoInfoBox} from '../VideoInfoBox';
2 | import {shallow} from 'enzyme';
3 | import React from 'react';
4 |
5 | describe('VideoInfoBox', () => {
6 | test('renders collapsed', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | test('renders expanded', () => {
11 | const wrapper = shallow( );
12 | wrapper.setState({collapsed: false});
13 | expect(wrapper).toMatchSnapshot();
14 | });
15 | });
--------------------------------------------------------------------------------
/src/components/VideoInfoBox/__tests__/__snapshots__/VideoInfoBox.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VideoInfoBox renders collapsed 1`] = `
`;
4 |
5 | exports[`VideoInfoBox renders expanded 1`] = `
`;
6 |
--------------------------------------------------------------------------------
/src/components/VideoList/VideoList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {SideBar} from '../../containers/SideBar/SideBar';
3 | import {InfiniteScroll} from '../InfiniteScroll/InfiniteScroll';
4 | import './VideoList.scss';
5 | import {VideoPreview} from '../VideoPreview/VideoPreview';
6 |
7 | export class VideoList extends React.Component {
8 | render() {
9 | const videoPreviews = this.getVideoPreviews();
10 | return (
11 |
12 |
13 |
14 |
15 | {videoPreviews}
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | getVideoPreviews() {
23 | if(!this.props.videos || !this.props.videos.length) {
24 | return null;
25 | }
26 | const firstVideo = this.props.videos[0];
27 | if (!firstVideo.snippet.description) {return null}
28 |
29 | return this.props.videos.map(video => (
30 | )
32 | );
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/src/components/VideoList/VideoList.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/shared.scss';
2 |
3 | .video-list {
4 | display: grid;
5 | grid: auto / auto;
6 | padding-left: 10rem;
7 | padding-top: 24px;
8 | margin-left: $sidebar-left-width;
9 | grid-row-gap: 10px;
10 | max-width: 900px;
11 | }
12 |
13 | @media(max-width: 1200px) {
14 | .video-list {
15 | padding-left: 10px;
16 | }
17 | }
--------------------------------------------------------------------------------
/src/components/VideoMetadata/VideoMetadata.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Button, Divider, Icon} from "semantic-ui-react";
3 | import './VideoMetadata.scss';
4 | import {Rating} from '../Rating/Rating';
5 |
6 | export function VideoMetadata(props) {
7 | if (!props.video || !props.video.statistics) {
8 | return
;
9 | }
10 | const viewCount = Number(props.video.statistics.viewCount).toLocaleString();
11 |
12 | return (
13 |
14 |
{props.video.snippet.title}
15 |
16 |
{viewCount} views
17 |
18 |
20 |
21 |
22 | Share
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
--------------------------------------------------------------------------------
/src/components/VideoMetadata/VideoMetadata.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/shared.scss';
2 |
3 | .video-metadata {
4 | color: #707070;
5 | font-size: 16px;
6 |
7 | h3 {
8 | font-weight: 400;
9 | color: $text-color-dark;
10 | }
11 |
12 | .video-stats {
13 | display: flex;
14 | justify-content: space-between;
15 | width: 100%;
16 | align-items: center;
17 |
18 | .video-actions {
19 | display: flex;
20 | align-items: center;
21 | & > *:not(:last-child) {
22 | margin-right: 8px;
23 | }
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/src/components/VideoMetadata/__tests__/VideoMetadata.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {VideoMetadata} from '../VideoMetadata';
4 |
5 | describe('VideoMetadata', () => {
6 | test('renders without props', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | test('renders with view count', () => {
11 | const wrapper = shallow();
12 | expect(wrapper).toMatchSnapshot();
13 | });
14 | });
--------------------------------------------------------------------------------
/src/components/VideoMetadata/__tests__/__snapshots__/VideoMetadata.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VideoMetadata renders with view count 1`] = `
`;
4 |
5 | exports[`VideoMetadata renders without props 1`] = `
`;
6 |
--------------------------------------------------------------------------------
/src/components/VideoPreview/VideoPreview.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Image} from 'semantic-ui-react';
3 | import './VideoPreview.scss';
4 |
5 | import TimeAgo from 'javascript-time-ago';
6 | import en from 'javascript-time-ago/locale/en';
7 | import {getShortNumberString} from '../../services/number/number-format';
8 | import {getVideoDurationString} from '../../services/date/date-format';
9 | import {Link} from 'react-router-dom';
10 |
11 | TimeAgo.locale(en);
12 | const timeAgo = new TimeAgo('en-US');
13 |
14 | export class VideoPreview extends React.Component {
15 | render() {
16 | const {video} = this.props;
17 | if (!video) {
18 | return
;
19 | }
20 |
21 | const duration = video.contentDetails ? video.contentDetails.duration : null;
22 | const videoDuration = getVideoDurationString(duration);
23 | const viewAndTimeString = VideoPreview.getFormattedViewAndTime(video);
24 | const horizontal = this.props.horizontal ? 'horizontal' : null;
25 | const expanded = this.props.expanded ? 'expanded' : null;
26 | const description = this.props.expanded ? video.snippet.description : null;
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | {videoDuration}
35 |
36 |
37 |
38 |
39 |
{video.snippet.title}
40 |
41 |
{video.snippet.channelTitle}
42 |
{viewAndTimeString}
43 |
{description}
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | static getFormattedViewAndTime(video) {
52 | const publicationDate = new Date(video.snippet.publishedAt);
53 | const viewCount = video.statistics ? video.statistics.viewCount : null;
54 | if(viewCount) {
55 | const viewCountShort = getShortNumberString(video.statistics.viewCount);
56 | return `${viewCountShort} views • ${timeAgo.format(publicationDate)}`;
57 | }
58 | return '';
59 | }
60 | }
--------------------------------------------------------------------------------
/src/components/VideoPreview/VideoPreview.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/shared.scss';
2 |
3 | .video-preview {
4 | display: grid;
5 | grid: 180px auto / 320px;
6 |
7 | /*
8 | override grid settings to make VideoPreview horizontal.
9 | Vertical is default
10 | */
11 | &.horizontal {
12 | grid: auto / 210px auto;
13 |
14 | .video-info {
15 | margin-top: 0;
16 | }
17 |
18 | &.expanded {
19 | grid: auto / 246px auto;
20 | }
21 | grid-column-gap: 4px;
22 | .video-info {
23 | grid-row: 1 / 2;
24 | grid-column: 2 / 3;
25 | }
26 | }
27 | }
28 |
29 | .video-info {
30 | margin-top: 8px;
31 | grid-row: 2 / 3; /* is overriden when VideoPreview is horizontal */
32 | grid-column: 1 / 2; /* is overriden when VideoPreview is horizontal */
33 | color: $text-color-dark;
34 |
35 | .video-preview-metadata-container {
36 | padding-top: 5px;
37 | font-size: 14px;
38 | color: #6e6e6e;
39 |
40 | .channel-title {
41 | white-space: nowrap;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | }
45 | }
46 |
47 | .show-max-two-lines {
48 | overflow: hidden;
49 | font-size: 16px;
50 | line-height: 1.4em;
51 | max-height: 2.8em;
52 | }
53 |
54 | .view-and-time {
55 | margin-bottom: 10px;
56 | }
57 |
58 | .semi-bold {
59 | &.expanded {
60 | font-weight: 500;
61 | font-size: 1.3rem;
62 | }
63 | font-weight: 600;
64 | }
65 | }
66 |
67 | .image-container {
68 | position: relative;
69 | grid-row: 1 / 2;
70 | grid-column: 1 / 2;
71 |
72 | /* Video duration label at bottom right */
73 | .time-label {
74 | position: absolute;
75 | background: $text-color-dark;
76 | bottom: 0;
77 | right: 0;
78 | opacity: 0.8;
79 | border-radius: 2px;
80 | font-weight: 500;
81 | color: white;
82 | margin: 4px;
83 | padding: 2px 4px;
84 | line-height: 12px;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/VideoPreview/__tests__/VideoPreview.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {VideoPreview} from '../VideoPreview';
4 |
5 | describe('VideoPreview', () => {
6 | test('renders vertically', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | test('renders horizontally', () => {
11 | const wrapper = shallow();
12 | expect(wrapper).toMatchSnapshot();
13 | });
14 | });
--------------------------------------------------------------------------------
/src/components/VideoPreview/__tests__/__snapshots__/VideoPreview.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VideoPreview renders horizontally 1`] = `
`;
4 |
5 | exports[`VideoPreview renders vertically 1`] = `
`;
6 |
--------------------------------------------------------------------------------
/src/containers/Comments/AddComment/AddComment.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './AddComment.scss';
3 | import {Form, Image, TextArea} from "semantic-ui-react";
4 |
5 | export function AddComment() {
6 | return (
7 |
8 |
9 |
11 |
12 |
13 | );
14 | }
--------------------------------------------------------------------------------
/src/containers/Comments/AddComment/AddComment.scss:
--------------------------------------------------------------------------------
1 | @import '../../../styles/shared.scss';
2 |
3 | .add-comment {
4 | display: flex;
5 | margin-top: 16px;
6 | margin-bottom: 16px;
7 |
8 | form {
9 | flex: 1;
10 | }
11 |
12 | .user-image {
13 | width: $avatar-diameter;
14 | height: $avatar-diameter;
15 | margin-right: $avatar-margin;
16 | }
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/src/containers/Comments/AddComment/__tests__/AddComment.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {AddComment} from '../AddComment';
4 |
5 | describe('AddComment', () => {
6 | test('AddComment renders correctly', () => {
7 | const wrapper = shallow(
8 |
9 | );
10 | expect(wrapper).toMatchSnapshot();
11 | });
12 | });
--------------------------------------------------------------------------------
/src/containers/Comments/AddComment/__tests__/__snapshots__/AddComment.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`AddComment AddComment renders correctly 1`] = `
4 |
7 |
14 |
24 |
25 | `;
26 |
--------------------------------------------------------------------------------
/src/containers/Comments/Comment/Comment.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './Comment.scss';
3 | import {Button, Image} from "semantic-ui-react";
4 | import {Rating} from '../../../components/Rating/Rating';
5 |
6 | export function Comment(props) {
7 | if (!props.comment) {
8 | return
;
9 | }
10 | const topLevelComment = props.comment.snippet.topLevelComment;
11 | const {authorProfileImageUrl, authorDisplayName, textOriginal} = topLevelComment.snippet;
12 | const likeCount = topLevelComment.snippet.likeCount;
13 |
14 | return (
15 |
16 |
17 |
18 |
{authorDisplayName}
19 |
{textOriginal}
20 |
21 | REPLY
22 |
23 |
24 |
25 | );
26 | }
--------------------------------------------------------------------------------
/src/containers/Comments/Comment/Comment.scss:
--------------------------------------------------------------------------------
1 | @import '../../../styles/shared.scss';
2 | .comment {
3 | display: flex;
4 | margin: 8px 0;
5 |
6 | .user-image {
7 | width: $avatar-diameter;
8 | height: $avatar-diameter;
9 | margin-right: $avatar-margin;
10 | flex-shrink: 0;
11 | }
12 |
13 | .user-name {
14 | font-weight: 600;
15 | margin-bottom: 4px;
16 | }
17 | .comment-actions {
18 | margin-top: 4px;
19 | button {
20 | margin-left: 8px;
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/src/containers/Comments/Comment/__tests__/Comment.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {Comment} from '../Comment';
4 |
5 | describe('Comment', () => {
6 | test('renders Comment', () => {
7 | const wrapper = shallow( );
8 | expect(wrapper).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/containers/Comments/Comment/__tests__/__snapshots__/Comment.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Comment renders Comment 1`] = `
`;
4 |
--------------------------------------------------------------------------------
/src/containers/Comments/Comments.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {CommentsHeader} from "./CommentsHeader/CommentsHeader";
3 | import {Comment} from './Comment/Comment';
4 | import {AddComment} from './AddComment/AddComment';
5 |
6 | export class Comments extends React.Component {
7 | render() {
8 | if (!this.props.comments) {
9 | return
;
10 | }
11 |
12 | const comments = this.props.comments.map((comment) => {
13 | return
14 | });
15 |
16 | return(
17 |
18 |
19 |
20 | {comments}
21 |
22 | );
23 | }
24 | }
--------------------------------------------------------------------------------
/src/containers/Comments/CommentsHeader/CommentsHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Button, Icon} from "semantic-ui-react";
3 | import './CommentsHeader.scss';
4 |
5 | export function CommentsHeader(props) {
6 | return (
7 |
8 |
{props.amountComments} Comments
9 |
10 |
11 | Sort by
12 |
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/src/containers/Comments/CommentsHeader/CommentsHeader.scss:
--------------------------------------------------------------------------------
1 | .comments-header {
2 | h4 {
3 | display: inline-block;
4 | margin-right: 16px;
5 | }
6 | }
--------------------------------------------------------------------------------
/src/containers/Comments/CommentsHeader/__tests__/CommentsHeader.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {CommentsHeader} from '../CommentsHeader';
4 |
5 | describe('CommentsHeader', () => {
6 | test('CommentsHeader renders with props.amountComments = null', () => {
7 | const wrapper = shallow(
8 |
9 | );
10 | expect(wrapper).toMatchSnapshot();
11 | });
12 |
13 | test('CommentsHeader renders with props.amountComments = 0', () => {
14 | const wrapper = shallow(
15 |
16 | );
17 | expect(wrapper).toMatchSnapshot();
18 | });
19 | });
--------------------------------------------------------------------------------
/src/containers/Comments/CommentsHeader/__tests__/__snapshots__/CommentsHeader.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`CommentsHeader CommentsHeader renders with props.amountComments = 0 1`] = `
4 |
7 |
8 | 123
9 | Comments
10 |
11 |
18 |
22 | Sort by
23 |
24 |
25 | `;
26 |
27 | exports[`CommentsHeader CommentsHeader renders with props.amountComments = null 1`] = `
28 |
31 |
32 | Comments
33 |
34 |
41 |
45 | Sort by
46 |
47 |
48 | `;
49 |
--------------------------------------------------------------------------------
/src/containers/Comments/__tests__/Comments.unit.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {shallow} from 'enzyme';
3 | import {Comments} from '../Comments';
4 |
5 | describe('Comments', () => {
6 | test('renders without props', () => {
7 | const wrapper = shallow(
8 |
9 | );
10 | expect(wrapper).toMatchSnapshot();
11 | });
12 |
13 | test('renders without amountComments', () => {
14 | const wrapper = shallow(
15 |
16 | );
17 | expect(wrapper).toMatchSnapshot();
18 | });
19 | });
20 |
21 |
--------------------------------------------------------------------------------
/src/containers/Comments/__tests__/__snapshots__/Comments.unit.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Comments renders without amountComments 1`] = `
`;
4 |
5 | exports[`Comments renders without props 1`] = `
`;
6 |
--------------------------------------------------------------------------------
/src/containers/HeaderNav/HeaderNav.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Form, Icon, Image, Input, Menu} from 'semantic-ui-react';
3 | import './HeaderNav.scss';
4 | import logo from '../../assets/images/logo.jpg';
5 | import {Link, withRouter} from 'react-router-dom';
6 |
7 | export class HeaderNav extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {
11 | query: '',
12 | };
13 | }
14 | render() {
15 | return (
16 | // 1
17 |
18 | {/* 2 */}
19 |
20 |
21 |
22 | {/* 3 */}
23 |
24 |
25 |
28 |
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 |
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 |
23 |
24 |
25 |
26 |
27 |
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;
--------------------------------------------------------------------------------