Browse Source

add infinite video grid component

Klaudiusz Dembler 4 years ago
parent
commit
11c76e9e32

+ 6 - 0
packages/app/src/api/client.ts

@@ -1,11 +1,17 @@
 import { ApolloClient, InMemoryCache } from '@apollo/client'
 import { parseISO } from 'date-fns'
 import '@/mocking/server'
+import { offsetLimitPagination } from '@apollo/client/utilities'
 
 const apolloClient = new ApolloClient({
   uri: '/graphql',
   cache: new InMemoryCache({
     typePolicies: {
+      Query: {
+        fields: {
+          videos: offsetLimitPagination(),
+        },
+      },
       Video: {
         fields: {
           publishedOnJoystreamAt: {

+ 5 - 5
packages/app/src/api/queries/__generated__/ChannelFields.ts

@@ -8,9 +8,9 @@
 // ====================================================
 
 export interface ChannelFields {
-  __typename: 'Channel'
-  id: string
-  handle: string
-  avatarPhotoURL: string
-  totalViews: number
+  __typename: "Channel";
+  id: string;
+  handle: string;
+  avatarPhotoURL: string;
+  totalViews: number;
 }

+ 6 - 6
packages/app/src/api/queries/__generated__/GetNewestChannels.ts

@@ -8,13 +8,13 @@
 // ====================================================
 
 export interface GetNewestChannels_channels {
-  __typename: 'Channel'
-  id: string
-  handle: string
-  avatarPhotoURL: string
-  totalViews: number
+  __typename: "Channel";
+  id: string;
+  handle: string;
+  avatarPhotoURL: string;
+  totalViews: number;
 }
 
 export interface GetNewestChannels {
-  channels: GetNewestChannels_channels[]
+  channels: GetNewestChannels_channels[];
 }

+ 5 - 0
packages/app/src/api/queries/__generated__/GetNewestVideos.ts

@@ -52,3 +52,8 @@ export interface GetNewestVideos_videos {
 export interface GetNewestVideos {
   videos: GetNewestVideos_videos[]
 }
+
+export interface GetNewestVideosVariables {
+  offset?: number | null
+  limit?: number | null
+}

+ 26 - 28
packages/app/src/api/queries/__generated__/GetVideo.ts

@@ -8,51 +8,49 @@
 // ====================================================
 
 export interface GetVideo_video_media_location_HTTPVideoMediaLocation {
-  __typename: 'HTTPVideoMediaLocation'
-  host: string
-  port: number | null
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
 }
 
 export interface GetVideo_video_media_location_JoystreamVideoMediaLocation {
-  __typename: 'JoystreamVideoMediaLocation'
-  dataObjectID: string
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
 }
 
-export type GetVideo_video_media_location =
-  | GetVideo_video_media_location_HTTPVideoMediaLocation
-  | GetVideo_video_media_location_JoystreamVideoMediaLocation
+export type GetVideo_video_media_location = GetVideo_video_media_location_HTTPVideoMediaLocation | GetVideo_video_media_location_JoystreamVideoMediaLocation;
 
 export interface GetVideo_video_media {
-  __typename: 'VideoMedia'
-  pixelHeight: number
-  pixelWidth: number
-  location: GetVideo_video_media_location
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetVideo_video_media_location;
 }
 
 export interface GetVideo_video_channel {
-  __typename: 'Channel'
-  id: string
-  avatarPhotoURL: string
-  handle: string
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string;
+  handle: string;
 }
 
 export interface GetVideo_video {
-  __typename: 'Video'
-  id: string
-  title: string
-  description: string
-  views: number
-  duration: number
-  thumbnailURL: string
-  publishedOnJoystreamAt: GQLDate
-  media: GetVideo_video_media
-  channel: GetVideo_video_channel
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetVideo_video_media;
+  channel: GetVideo_video_channel;
 }
 
 export interface GetVideo {
-  video: GetVideo_video | null
+  video: GetVideo_video | null;
 }
 
 export interface GetVideoVariables {
-  id: string
+  id: string;
 }

+ 2 - 2
packages/app/src/api/queries/videos.ts

@@ -32,8 +32,8 @@ const videoFieldsFragment = gql`
 
 // TODO: Add proper query params (order, limit, etc.)
 export const GET_NEWEST_VIDEOS = gql`
-  query GetNewestVideos {
-    videos {
+  query GetNewestVideos($offset: Int, $limit: Int) {
+    videos(offset: $offset, limit: $limit) {
       ...VideoFields
     }
   }

+ 5 - 1
packages/app/src/components/VideoGallery.tsx

@@ -3,7 +3,7 @@ import { css, SerializedStyles } from '@emotion/core'
 import styled from '@emotion/styled'
 import { navigate } from '@reach/router'
 
-import { Gallery, VideoPreview, VideoPreviewBase } from '@/shared/components'
+import { Gallery, MAX_VIDEO_PREVIEW_WIDTH, VideoPreview, VideoPreviewBase } from '@/shared/components'
 import { VideoFields } from '@/api/queries/__generated__/VideoFields'
 import { CAROUSEL_CONTROL_SIZE } from '@/shared/components/Carousel'
 import routes from '@/config/routes'
@@ -78,11 +78,15 @@ const StyledVideoPreviewBase = styled(VideoPreviewBase)`
   & + & {
     margin-left: 1.25rem;
   }
+
+  width: ${MAX_VIDEO_PREVIEW_WIDTH};
 `
 const StyledVideoPreview = styled(VideoPreview)`
   & + & {
     margin-left: 1.25rem;
   }
+
+  width: ${MAX_VIDEO_PREVIEW_WIDTH};
 `
 
 export default VideoGallery

+ 14 - 3
packages/app/src/mocking/server.ts

@@ -4,15 +4,26 @@ import { shuffle } from 'lodash'
 
 import schema from '../schema.graphql'
 import { mockChannels, mockVideos } from '@/mocking/data'
+import { GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
 
 createServer({
   routes() {
     const graphQLHandler = createGraphQLHandler(schema, this.schema, {
       resolvers: {
         Query: {
-          videos: (...params: unknown[]) => {
-            const videos = mirageGraphQLFieldResolver(...params)
-            return shuffle(videos)
+          videos: (obj: unknown, args: GetNewestVideosVariables, context: unknown, info: unknown) => {
+            const videos = mirageGraphQLFieldResolver(obj, {}, context, info)
+
+            const { limit } = args
+            if (!limit) {
+              return videos
+            }
+
+            const videosCount = videos.length
+            const repeatVideosCount = Math.ceil(limit / videosCount)
+            const repeatedVideos = Array.from({ length: repeatVideosCount }, () => videos).flat()
+            const slicedVideos = repeatedVideos.slice(0, limit)
+            return slicedVideos
           },
           featured_videos: (...params: unknown[]) => {
             const videos = mirageGraphQLFieldResolver(...params)

+ 104 - 0
packages/app/src/shared/components/InfiniteVideoGrid/InfiniteVideoGrid.tsx

@@ -0,0 +1,104 @@
+import React, { useEffect, useState } from 'react'
+import styled from '@emotion/styled'
+import { VideoFields } from '@/api/queries/__generated__/VideoFields'
+import { spacing, typography } from '../../theme'
+import { VideoPreview, VideoPreviewBase } from '..'
+import sizes from '@/shared/theme/sizes'
+import { debounce } from 'lodash'
+
+type InfiniteVideoGridProps = {
+  title?: string
+  videos?: VideoFields[]
+  loadVideos: (offset: number, limit: number) => void
+  className?: string
+}
+
+const INITIAL_ROWS = 2
+const VIDEOS_PER_ROW = 4
+
+const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({ title, videos, loadVideos, className }) => {
+  // TODO: base this on the container width and some responsive items/row
+  const videosPerRow = VIDEOS_PER_ROW
+
+  const [currentRowsCount, setCurrentRowsCount] = useState(INITIAL_ROWS)
+
+  const targetVideosCount = currentRowsCount * videosPerRow
+  const loadedVideosCount = videos?.length || 0
+
+  useEffect(() => {
+    if (targetVideosCount > loadedVideosCount) {
+      const missingVideosCount = targetVideosCount - loadedVideosCount
+      loadVideos(loadedVideosCount, missingVideosCount)
+      // TODO: handle a situation when there are no more videos to fetch
+      // this will require query node to provide some pagination metadata (total items count at minimum)
+    }
+  }, [loadedVideosCount, targetVideosCount, loadVideos])
+
+  const videoRowsCount = Math.floor(loadedVideosCount / videosPerRow)
+  const displayedVideos = videos?.slice(0, videoRowsCount * videosPerRow) || []
+  const placeholderRowsCount = currentRowsCount - videoRowsCount
+  const placeholdersCount = placeholderRowsCount * videosPerRow
+
+  useEffect(() => {
+    const scrollHandler = debounce(() => {
+      const scrolledToBottom =
+        window.innerHeight + document.documentElement.scrollTop === document.documentElement.offsetHeight
+
+      if (scrolledToBottom && placeholdersCount === 0) {
+        setCurrentRowsCount(currentRowsCount + 2)
+      }
+    }, 100)
+    window.addEventListener('scroll', scrollHandler)
+    return () => {
+      window.removeEventListener('scroll', scrollHandler)
+    }
+  }, [currentRowsCount, placeholdersCount])
+
+  const gridContent = (
+    <>
+      {displayedVideos.map((v, idx) => (
+        <StyledVideoPreview
+          title={v.title}
+          channelName={v.channel.handle}
+          createdAt={v.publishedOnJoystreamAt}
+          views={v.views}
+          posterURL={v.thumbnailURL}
+          key={`${v.id}-${idx}`} // TODO: remove idx from key once we get the real data without duplicated IDs
+        />
+      ))}
+      {Array.from({ length: placeholdersCount }, (_, idx) => (
+        <StyledVideoPreviewBase key={idx} />
+      ))}
+    </>
+  )
+
+  return (
+    <section className={className}>
+      {title && <Title>{title}</Title>}
+      <Grid videosPerRow={videosPerRow}>{gridContent}</Grid>
+    </section>
+  )
+}
+
+const Title = styled.h4`
+  margin: 0 0 ${sizes.b4};
+  font-size: ${typography.sizes.h5};
+`
+
+const StyledVideoPreview = styled(VideoPreview)`
+  margin: 0 auto;
+  width: 100%;
+`
+
+const StyledVideoPreviewBase = styled(VideoPreviewBase)`
+  margin: 0 auto;
+  width: 100%;
+`
+
+const Grid = styled.div<{ videosPerRow: number }>`
+  display: grid;
+  grid-template-columns: repeat(${({ videosPerRow }) => videosPerRow}, 1fr);
+  grid-gap: ${spacing.xl};
+`
+
+export default InfiniteVideoGrid

+ 2 - 0
packages/app/src/shared/components/InfiniteVideoGrid/index.ts

@@ -0,0 +1,2 @@
+import InfiniteVideoGrid from './InfiniteVideoGrid'
+export default InfiniteVideoGrid

+ 4 - 1
packages/app/src/shared/components/VideoPreview/VideoPreviewBase.styles.tsx

@@ -8,8 +8,10 @@ type ContainerProps = {
   clickable: boolean
 }
 
+export const MAX_VIDEO_PREVIEW_WIDTH = '320px'
+
 export const CoverContainer = styled.div`
-  width: 320px;
+  width: 100%;
   height: 190px;
 
   transition-property: box-shadow, transform;
@@ -20,6 +22,7 @@ export const CoverContainer = styled.div`
 `
 
 export const Container = styled.article<ContainerProps>`
+  max-width: ${MAX_VIDEO_PREVIEW_WIDTH};
   color: ${colors.gray[300]};
   cursor: ${({ clickable }) => (clickable ? 'pointer' : 'auto')};
   display: inline-block;

+ 2 - 1
packages/app/src/shared/components/VideoPreview/index.tsx

@@ -1,4 +1,5 @@
 import VideoPreview from './VideoPreview'
 import VideoPreviewBase from './VideoPreviewBase'
+import { MAX_VIDEO_PREVIEW_WIDTH } from './VideoPreviewBase.styles'
 
-export { VideoPreview, VideoPreviewBase }
+export { VideoPreview, VideoPreviewBase, MAX_VIDEO_PREVIEW_WIDTH }

+ 2 - 1
packages/app/src/shared/components/index.ts

@@ -14,7 +14,7 @@ export { default as Tab } from './Tabs/Tab'
 export { default as Tag } from './Tag'
 export { default as TextField } from './TextField'
 export { default as Typography } from './Typography'
-export { VideoPreview, VideoPreviewBase } from './VideoPreview'
+export { VideoPreview, VideoPreviewBase, MAX_VIDEO_PREVIEW_WIDTH } from './VideoPreview'
 export { default as VideoPlayer } from './VideoPlayer'
 export { default as SeriesPreview } from './SeriesPreview'
 export { ChannelPreview, ChannelPreviewBase } from './ChannelPreview'
@@ -24,3 +24,4 @@ export { default as Sidenav, SIDENAV_WIDTH, EXPANDED_SIDENAV_WIDTH, NavItem } fr
 export { default as ChannelAvatar } from './ChannelAvatar'
 export { default as GlobalStyle } from './GlobalStyle'
 export { default as Placeholder } from './Placeholder'
+export { default as InfiniteVideoGrid } from './InfiniteVideoGrid'

+ 28 - 6
packages/app/src/views/HomeView.tsx

@@ -1,13 +1,14 @@
+import React, { useCallback } from 'react'
 import { css } from '@emotion/core'
-import React from 'react'
+import styled from '@emotion/styled'
 import { ChannelGallery, Hero, Main, VideoGallery } from '@/components'
 import { RouteComponentProps } from '@reach/router'
-import { useQuery } from '@apollo/client'
-import { GET_FEATURED_VIDEOS, GET_NEWEST_VIDEOS } from '@/api/queries'
-import { GetNewestVideos } from '@/api/queries/__generated__/GetNewestVideos'
+import { useLazyQuery, useQuery } from '@apollo/client'
+import { GET_FEATURED_VIDEOS, GET_NEWEST_VIDEOS, GET_NEWEST_CHANNELS } from '@/api/queries'
+import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
 import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos'
 import { GetNewestChannels } from '@/api/queries/__generated__/GetNewestChannels'
-import { GET_NEWEST_CHANNELS } from '@/api/queries/channels'
+import { InfiniteVideoGrid } from '@/shared/components'
 
 const backgroundImg = 'https://source.unsplash.com/Nyvq2juw4_o/1920x1080'
 
@@ -15,6 +16,22 @@ const HomeView: React.FC<RouteComponentProps> = () => {
   const { loading: newestVideosLoading, data: newestVideosData } = useQuery<GetNewestVideos>(GET_NEWEST_VIDEOS)
   const { loading: featuredVideosLoading, data: featuredVideosData } = useQuery<GetFeaturedVideos>(GET_FEATURED_VIDEOS)
   const { loading: newestChannelsLoading, data: newestChannelsData } = useQuery<GetNewestChannels>(GET_NEWEST_CHANNELS)
+  const [getNextVideos, { data: nextVideosData, fetchMore: fetchMoreNextVideos }] = useLazyQuery<
+    GetNewestVideos,
+    GetNewestVideosVariables
+  >(GET_NEWEST_VIDEOS, { fetchPolicy: 'cache-and-network' })
+
+  const loadVideos = useCallback(
+    (offset: number, limit: number) => {
+      const variables = { offset, limit }
+      if (!fetchMoreNextVideos) {
+        getNextVideos({ variables })
+      } else {
+        fetchMoreNextVideos({ variables })
+      }
+    },
+    [getNextVideos, fetchMoreNextVideos]
+  )
 
   return (
     <>
@@ -38,10 +55,15 @@ const HomeView: React.FC<RouteComponentProps> = () => {
           loading={newestChannelsLoading}
           channels={newestChannelsData?.channels}
         />
-        {/*  infinite video loader */}
+        <StyledInfiniteVideoGrid title="More videos" videos={nextVideosData?.videos} loadVideos={loadVideos} />
       </Main>
     </>
   )
 }
 
+const StyledInfiniteVideoGrid = styled(InfiniteVideoGrid)`
+  margin: 0;
+  padding-bottom: 4rem;
+`
+
 export default HomeView