From bbfe349b52d308620796b37aaf986a0ed1ff0006 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 11 Sep 2020 15:46:10 +0900 Subject: [PATCH] feat(frontend): basic discover page (only movies) (#74) --- package.json | 2 + src/components/Discover/index.tsx | 92 +++++++++++++++++++++++++ src/components/Layout/Sidebar/index.tsx | 18 ++--- src/components/Layout/index.tsx | 8 +-- src/components/Login/index.tsx | 6 +- src/components/Search/index.tsx | 4 +- src/components/TitleCard/index.tsx | 2 +- src/hooks/useVerticalScroll.ts | 80 +++++++++++++++++++++ src/pages/index.tsx | 15 +--- src/styles/globals.css | 4 ++ yarn.lock | 5 ++ 11 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 src/components/Discover/index.tsx create mode 100644 src/hooks/useVerticalScroll.ts diff --git a/package.json b/package.json index 40dcf5000..82cf0cf42 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "express": "^4.17.1", "express-openapi-validator": "^3.16.15", "express-session": "^1.17.1", + "lodash": "^4.17.20", "next": "9.5.3", "react": "16.13.1", "react-dom": "16.13.1", @@ -39,6 +40,7 @@ "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.8", "@types/express-session": "^1.17.0", + "@types/lodash": "^4.14.161", "@types/node": "^14.6.4", "@types/react": "^16.9.49", "@types/react-transition-group": "^4.4.0", diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx new file mode 100644 index 000000000..968ec5fe9 --- /dev/null +++ b/src/components/Discover/index.tsx @@ -0,0 +1,92 @@ +import React, { useRef } from 'react'; +import { useSWRInfinite } from 'swr'; +import type { MovieResult } from '../../../server/models/Search'; +import TitleCard from '../TitleCard'; +import useVerticalScroll from '../../hooks/useVerticalScroll'; + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: MovieResult[]; +} + +const getKey = (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/discover/movies?page=${pageIndex + 1}`; +}; + +const Discover: React.FC = () => { + const { data, error, size, setSize } = useSWRInfinite(getKey, { + initialSize: 3, + }); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + useVerticalScroll(() => { + setSize(size + 1); + }, !isLoadingMore && !isLoadingInitialData); + + if (error) { + return
{error}
; + } + + if (!data && !error) { + return
loading!
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as MovieResult[] + ); + + return ( + <> +
+
+

+ Discover +

+
+
+ + + + +
+
+ + + ); +}; + +export default Discover; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 57259b4e1..1df4cac48 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -67,16 +67,17 @@ const Sidebar: React.FC = ({ open, setClosed }) => { className="group flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white bg-gray-900 focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150" > Dashboard @@ -93,7 +94,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { -
+
@@ -108,14 +109,15 @@ const Sidebar: React.FC = ({ open, setClosed }) => { Dashboard diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index ec6229c46..cf6354e84 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -8,11 +8,11 @@ const Layout: React.FC = ({ children }) => { const [isSidebarOpen, setSidebarOpen] = useState(false); return ( -
+
setSidebarOpen(false)} /> -
-
+
+
diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 4fb4a7242..e444ffe7c 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -35,15 +35,15 @@ const Login: React.FC = () => { }, [user, router]); return ( -
+
-

+

Log in to continue

-
+
setAuthToken(authToken)} /> diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b7017a936..e9bdfdbf8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -7,7 +7,7 @@ const Search: React.FC = () => { -
+
{
diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 710d3aed9..e767ec64a 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -61,7 +61,7 @@ const TitleCard: React.FC = ({ - User Score: {userScore}/100 + User Score: {userScore}
diff --git a/src/hooks/useVerticalScroll.ts b/src/hooks/useVerticalScroll.ts new file mode 100644 index 000000000..0d7c2cfda --- /dev/null +++ b/src/hooks/useVerticalScroll.ts @@ -0,0 +1,80 @@ +import { useState, useEffect, useRef, MutableRefObject } from 'react'; +import { debounce } from 'lodash'; + +const IS_SCROLLING_CHECK_THROTTLE = 200; +const BUFFER_HEIGHT = 200; + +/** + * useVerticalScroll is a custom hook to handle infinite scrolling + * + * @param callback Callback is executed when page reaches bottom + * @param shouldFetch Disables callback if true + */ +const useVerticalScroll = ( + callback: () => void, + shouldFetch: boolean +): boolean => { + const [isScrolling, setScrolling] = useState(false); + + type SetTimeoutReturnType = ReturnType; + const scrollingTimer: MutableRefObject< + SetTimeoutReturnType | undefined + > = useRef(); + + const runCallback = () => { + if (shouldFetch) { + const scrollTop = Math.max( + window.pageYOffset, + document.documentElement.scrollTop, + document.body.scrollTop + ); + if ( + window.innerHeight + scrollTop >= + document.documentElement.offsetHeight - BUFFER_HEIGHT + ) { + callback(); + } + } + }; + + const debouncedCallback = debounce(runCallback, 50); + + useEffect(() => { + runCallback(); + }); + + useEffect(() => { + const onScroll = () => { + if (scrollingTimer.current !== undefined) { + clearTimeout(scrollingTimer.current); + } + if (!isScrolling) { + setScrolling(true); + } + + scrollingTimer.current = setTimeout(() => { + setScrolling(false); + }, IS_SCROLLING_CHECK_THROTTLE); + debouncedCallback(); + }; + + const onResize = () => { + debouncedCallback(); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onResize, { passive: true }); + + return () => { + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onResize); + + if (scrollingTimer.current !== undefined) { + clearTimeout(scrollingTimer.current); + } + }; + }); + + return isScrolling; +}; + +export default useVerticalScroll; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 9cebb384a..1c15c0bd4 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,20 +1,9 @@ import React from 'react'; import type { NextPage } from 'next'; -import TitleCard from '../components/TitleCard'; +import Discover from '../components/Discover'; const Index: NextPage = () => { - return ( - <> - - - ); + return ; }; export default Index; diff --git a/src/styles/globals.css b/src/styles/globals.css index 10505e6b0..477388114 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2,6 +2,10 @@ @tailwind components; @tailwind utilities; +body { + @apply bg-cool-gray-900; +} + .plex-button { @apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center; background-color: #cc7b19; diff --git a/yarn.lock b/yarn.lock index 39ba0d6a9..4defd0916 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1520,6 +1520,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== +"@types/lodash@^4.14.161": + version "4.14.161" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18" + integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA== + "@types/mime@*": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"