Browse Source

add discover screen, fix some apollo client issues

Klaudiusz Dembler 4 years ago
parent
commit
c60b94fff0
53 changed files with 728 additions and 486 deletions
  1. 1 6
      .eslintrc.js
  2. 2 1
      .prettierignore
  3. 69 2
      packages/app/src/api/client.ts
  4. 14 0
      packages/app/src/api/queries/__generated__/CategoryFields.ts
  5. 2 2
      packages/app/src/api/queries/__generated__/ChannelFields.ts
  6. 18 0
      packages/app/src/api/queries/__generated__/GetCategories.ts
  7. 9 3
      packages/app/src/api/queries/__generated__/GetChannel.ts
  8. 7 1
      packages/app/src/api/queries/__generated__/GetFeaturedVideos.ts
  9. 2 2
      packages/app/src/api/queries/__generated__/GetNewestChannels.ts
  10. 0 57
      packages/app/src/api/queries/__generated__/GetNewestVideos.ts
  11. 7 1
      packages/app/src/api/queries/__generated__/GetVideo.ts
  12. 64 0
      packages/app/src/api/queries/__generated__/GetVideos.ts
  13. 9 3
      packages/app/src/api/queries/__generated__/Search.ts
  14. 7 1
      packages/app/src/api/queries/__generated__/VideoFields.ts
  15. 0 24
      packages/app/src/api/queries/__generated__/channel.ts
  16. 17 0
      packages/app/src/api/queries/categories.ts
  17. 1 0
      packages/app/src/api/queries/index.ts
  18. 6 4
      packages/app/src/api/queries/videos.ts
  19. 6 0
      packages/app/src/components/Hero.tsx
  20. 12 4
      packages/app/src/components/LayoutWithRouting.tsx
  21. 0 21
      packages/app/src/components/Main.tsx
  22. 0 36
      packages/app/src/components/TagsGallery.tsx
  23. 0 2
      packages/app/src/components/index.ts
  24. 1 0
      packages/app/src/config/routes.ts
  25. 1 0
      packages/app/src/mocking/data/index.ts
  26. 26 0
      packages/app/src/mocking/data/mockCategories.ts
  27. 1 1
      packages/app/src/mocking/data/mockVideos.ts
  28. 25 13
      packages/app/src/mocking/server.ts
  29. 23 17
      packages/app/src/schema.graphql
  30. 2 4
      packages/app/src/shared/components/Button/Button.style.ts
  31. 57 0
      packages/app/src/shared/components/CategoryPicker/CategoryPicker.tsx
  32. 3 0
      packages/app/src/shared/components/CategoryPicker/index.ts
  33. 1 1
      packages/app/src/shared/components/ChannelAvatar/ChannelAvatar.tsx
  34. 1 1
      packages/app/src/shared/components/ChannelPreview/ChannelPreview.tsx
  35. 27 11
      packages/app/src/shared/components/InfiniteVideoGrid/InfiniteVideoGrid.tsx
  36. 2 2
      packages/app/src/shared/components/InfiniteVideoGrid/index.ts
  37. 6 4
      packages/app/src/shared/components/Placeholder/Placeholder.tsx
  38. 0 42
      packages/app/src/shared/components/TagButton/TagButton.style.ts
  39. 0 16
      packages/app/src/shared/components/TagButton/TagButton.tsx
  40. 0 2
      packages/app/src/shared/components/TagButton/index.ts
  41. 22 22
      packages/app/src/shared/components/ToggleButton/ToggleButton.styles.tsx
  42. 21 6
      packages/app/src/shared/components/ToggleButton/ToggleButton.tsx
  43. 88 116
      packages/app/src/shared/components/Typography/Typography.style.ts
  44. 26 8
      packages/app/src/shared/components/Typography/Typography.tsx
  45. 0 1
      packages/app/src/shared/components/VideoPlayer/videoJsPlayer.ts
  46. 2 2
      packages/app/src/shared/components/index.ts
  47. 6 6
      packages/app/src/shared/theme/typography.ts
  48. 86 0
      packages/app/src/views/BrowseView.tsx
  49. 1 1
      packages/app/src/views/ChannelView/ChannelView.style.tsx
  50. 33 27
      packages/app/src/views/HomeView.tsx
  51. 10 11
      packages/app/src/views/SearchView.tsx
  52. 1 1
      packages/app/src/views/VideoView/VideoView.style.tsx
  53. 3 1
      packages/app/src/views/index.ts

+ 1 - 6
.eslintrc.js

@@ -7,12 +7,7 @@ module.exports = {
   },
   extends: ['plugin:react-hooks/recommended', '@joystream/eslint-config'],
   rules: {
-    camelcase: [
-      'warn',
-      {
-        ignoreImports: true,
-      },
-    ],
+    camelcase: ['off'],
     'react/prop-types': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/no-empty-function': 'warn',

+ 2 - 1
.prettierignore

@@ -2,4 +2,5 @@ node_modules/
 dist/
 .yarn/
 storybook-static/
-.coverage
+.coverage
+__generated__/

+ 69 - 2
packages/app/src/api/client.ts

@@ -1,5 +1,6 @@
 import { ApolloClient, InMemoryCache } from '@apollo/client'
 import { parseISO } from 'date-fns'
+
 import '@/mocking/server'
 import { offsetLimitPagination } from '@apollo/client/utilities'
 
@@ -9,13 +10,22 @@ const apolloClient = new ApolloClient({
     typePolicies: {
       Query: {
         fields: {
-          videos: offsetLimitPagination(),
+          videos: offsetLimitPagination((args) => {
+            // make sure queries asking for a specific category are separated in cache
+            return args?.where?.categoryId_eq
+          }),
         },
       },
       Video: {
         fields: {
           publishedOnJoystreamAt: {
-            merge(_, publishedOnJoystreamAt: string): Date {
+            merge(_, publishedOnJoystreamAt: string | Date): Date {
+              if (typeof publishedOnJoystreamAt !== 'string') {
+                // TODO: investigate further
+                // rarely, for some reason the object that arrives here is already a date object
+                // in this case parsing attempt will cause an error
+                return publishedOnJoystreamAt
+              }
               return parseISO(publishedOnJoystreamAt)
             },
           },
@@ -29,3 +39,60 @@ const apolloClient = new ApolloClient({
 })
 
 export default apolloClient
+
+// 2020-09-15
+// the following code fragment was written as part of solving apollo client pagination issues
+// it features an example of more advanced cache handling with custom merge/read operations
+// at some point it may prove useful so leaving it here for now
+
+// // based on FieldPolicy from '@apollo/client/cache/inmemory/policies'
+// type TypeableFieldMergeFunction<TExisting = any, TIncoming = TExisting, TArgs = Record<string, unknown>> = (
+//   existing: SafeReadonly<TExisting> | undefined,
+//   incoming: SafeReadonly<TIncoming>,
+//   options: FieldFunctionOptions<TArgs, TArgs>
+// ) => SafeReadonly<TExisting>
+// type TypeableFieldReadFunction<TExisting = any, TReadResult = TExisting, TArgs = Record<string, unknown>> = (
+//   existing: SafeReadonly<TExisting> | undefined,
+//   options: FieldFunctionOptions<TArgs, TArgs>
+// ) => TReadResult | undefined
+//
+// type VideosFieldPolicy = {
+//   merge: TypeableFieldMergeFunction<VideoFields[], VideoFields[], GetVideosVariables>
+//   read?: TypeableFieldReadFunction<VideoFields[], VideoFields[], GetVideosVariables>
+// }
+
+// {
+// merge: (existing, incoming, { variables }) => {
+//   // based on offsetLimitPagination from '@apollo/client/utilities'
+//   const merged = existing ? existing.slice(0) : []
+//   const start = variables?.offset || 0
+//   const end = start + incoming.length
+//   for (let i = start; i < end; ++i) {
+//     merged[i] = incoming[i - start]
+//   }
+//   // console.log({ merged })
+//   return merged
+// },
+// read: (existing, { variables, readField }) => {
+//   // console.log('read')
+//   // console.log({ variables })
+//   if (variables?.categoryId) {
+//     console.log({ existing })
+//     const filtered = existing?.filter((v) => {
+//       let categoryId = v.category?.id
+//       if (!v.category) {
+//         const categoryRef = readField('category', v as any)
+//         categoryId = readField('id', categoryRef as Reference) as string
+//       }
+//       return categoryId === variables.categoryId
+//     })
+//     console.log({ filtered })
+//     if (!filtered?.length) {
+//       return
+//     }
+//     return filtered
+//   }
+//   return existing
+// },
+
+// } as VideosFieldPolicy,

+ 14 - 0
packages/app/src/api/queries/__generated__/CategoryFields.ts

@@ -0,0 +1,14 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL fragment: CategoryFields
+// ====================================================
+
+export interface CategoryFields {
+  __typename: "Category";
+  id: string;
+  name: string;
+}

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

@@ -11,7 +11,7 @@ export interface ChannelFields {
   __typename: "Channel";
   id: string;
   handle: string;
-  avatarPhotoURL: string;
-  coverPhotoURL: string;
+  avatarPhotoURL: string | null;
+  coverPhotoURL: string | null;
   totalViews: number;
 }

+ 18 - 0
packages/app/src/api/queries/__generated__/GetCategories.ts

@@ -0,0 +1,18 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: GetCategories
+// ====================================================
+
+export interface GetCategories_categories {
+  __typename: "Category";
+  id: string;
+  name: string;
+}
+
+export interface GetCategories {
+  categories: GetCategories_categories[];
+}

+ 9 - 3
packages/app/src/api/queries/__generated__/GetChannel.ts

@@ -7,6 +7,11 @@
 // GraphQL query operation: GetChannel
 // ====================================================
 
+export interface GetChannel_channel_videos_category {
+  __typename: "Category";
+  id: string;
+}
+
 export interface GetChannel_channel_videos_media_location_HTTPVideoMediaLocation {
   __typename: "HTTPVideoMediaLocation";
   host: string;
@@ -30,7 +35,7 @@ export interface GetChannel_channel_videos_media {
 export interface GetChannel_channel_videos_channel {
   __typename: "Channel";
   id: string;
-  avatarPhotoURL: string;
+  avatarPhotoURL: string | null;
   handle: string;
 }
 
@@ -39,6 +44,7 @@ export interface GetChannel_channel_videos {
   id: string;
   title: string;
   description: string;
+  category: GetChannel_channel_videos_category;
   views: number;
   duration: number;
   thumbnailURL: string;
@@ -51,8 +57,8 @@ export interface GetChannel_channel {
   __typename: "Channel";
   id: string;
   handle: string;
-  avatarPhotoURL: string;
-  coverPhotoURL: string;
+  avatarPhotoURL: string | null;
+  coverPhotoURL: string | null;
   totalViews: number;
   videos: GetChannel_channel_videos[] | null;
 }

+ 7 - 1
packages/app/src/api/queries/__generated__/GetFeaturedVideos.ts

@@ -7,6 +7,11 @@
 // GraphQL query operation: GetFeaturedVideos
 // ====================================================
 
+export interface GetFeaturedVideos_featured_videos_category {
+  __typename: "Category";
+  id: string;
+}
+
 export interface GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation {
   __typename: "HTTPVideoMediaLocation";
   host: string;
@@ -30,7 +35,7 @@ export interface GetFeaturedVideos_featured_videos_media {
 export interface GetFeaturedVideos_featured_videos_channel {
   __typename: "Channel";
   id: string;
-  avatarPhotoURL: string;
+  avatarPhotoURL: string | null;
   handle: string;
 }
 
@@ -39,6 +44,7 @@ export interface GetFeaturedVideos_featured_videos {
   id: string;
   title: string;
   description: string;
+  category: GetFeaturedVideos_featured_videos_category;
   views: number;
   duration: number;
   thumbnailURL: string;

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

@@ -11,8 +11,8 @@ export interface GetNewestChannels_channels {
   __typename: "Channel";
   id: string;
   handle: string;
-  avatarPhotoURL: string;
-  coverPhotoURL: string;
+  avatarPhotoURL: string | null;
+  coverPhotoURL: string | null;
   totalViews: number;
 }
 

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

@@ -1,57 +0,0 @@
-/* tslint:disable */
-/* eslint-disable */
-// @generated
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetNewestVideos
-// ====================================================
-
-export interface GetNewestVideos_videos_media_location_HTTPVideoMediaLocation {
-  __typename: "HTTPVideoMediaLocation";
-  host: string;
-  port: number | null;
-}
-
-export interface GetNewestVideos_videos_media_location_JoystreamVideoMediaLocation {
-  __typename: "JoystreamVideoMediaLocation";
-  dataObjectID: string;
-}
-
-export type GetNewestVideos_videos_media_location = GetNewestVideos_videos_media_location_HTTPVideoMediaLocation | GetNewestVideos_videos_media_location_JoystreamVideoMediaLocation;
-
-export interface GetNewestVideos_videos_media {
-  __typename: "VideoMedia";
-  pixelHeight: number;
-  pixelWidth: number;
-  location: GetNewestVideos_videos_media_location;
-}
-
-export interface GetNewestVideos_videos_channel {
-  __typename: "Channel";
-  id: string;
-  avatarPhotoURL: string;
-  handle: string;
-}
-
-export interface GetNewestVideos_videos {
-  __typename: "Video";
-  id: string;
-  title: string;
-  description: string;
-  views: number;
-  duration: number;
-  thumbnailURL: string;
-  publishedOnJoystreamAt: GQLDate;
-  media: GetNewestVideos_videos_media;
-  channel: GetNewestVideos_videos_channel;
-}
-
-export interface GetNewestVideos {
-  videos: GetNewestVideos_videos[];
-}
-
-export interface GetNewestVideosVariables {
-  offset?: number | null;
-  limit?: number | null;
-}

+ 7 - 1
packages/app/src/api/queries/__generated__/GetVideo.ts

@@ -7,6 +7,11 @@
 // GraphQL query operation: GetVideo
 // ====================================================
 
+export interface GetVideo_video_category {
+  __typename: "Category";
+  id: string;
+}
+
 export interface GetVideo_video_media_location_HTTPVideoMediaLocation {
   __typename: "HTTPVideoMediaLocation";
   host: string;
@@ -30,7 +35,7 @@ export interface GetVideo_video_media {
 export interface GetVideo_video_channel {
   __typename: "Channel";
   id: string;
-  avatarPhotoURL: string;
+  avatarPhotoURL: string | null;
   handle: string;
 }
 
@@ -39,6 +44,7 @@ export interface GetVideo_video {
   id: string;
   title: string;
   description: string;
+  category: GetVideo_video_category;
   views: number;
   duration: number;
   thumbnailURL: string;

+ 64 - 0
packages/app/src/api/queries/__generated__/GetVideos.ts

@@ -0,0 +1,64 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: GetVideos
+// ====================================================
+
+export interface GetVideos_videos_category {
+  __typename: "Category";
+  id: string;
+}
+
+export interface GetVideos_videos_media_location_HTTPVideoMediaLocation {
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
+}
+
+export interface GetVideos_videos_media_location_JoystreamVideoMediaLocation {
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
+}
+
+export type GetVideos_videos_media_location = GetVideos_videos_media_location_HTTPVideoMediaLocation | GetVideos_videos_media_location_JoystreamVideoMediaLocation;
+
+export interface GetVideos_videos_media {
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetVideos_videos_media_location;
+}
+
+export interface GetVideos_videos_channel {
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string | null;
+  handle: string;
+}
+
+export interface GetVideos_videos {
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  category: GetVideos_videos_category;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetVideos_videos_media;
+  channel: GetVideos_videos_channel;
+}
+
+export interface GetVideos {
+  videos: GetVideos_videos[];
+}
+
+export interface GetVideosVariables {
+  offset?: number | null;
+  limit?: number | null;
+  categoryId?: string | null;
+}

+ 9 - 3
packages/app/src/api/queries/__generated__/Search.ts

@@ -7,6 +7,11 @@
 // GraphQL query operation: Search
 // ====================================================
 
+export interface Search_search_item_Video_category {
+  __typename: "Category";
+  id: string;
+}
+
 export interface Search_search_item_Video_media_location_HTTPVideoMediaLocation {
   __typename: "HTTPVideoMediaLocation";
   host: string;
@@ -30,7 +35,7 @@ export interface Search_search_item_Video_media {
 export interface Search_search_item_Video_channel {
   __typename: "Channel";
   id: string;
-  avatarPhotoURL: string;
+  avatarPhotoURL: string | null;
   handle: string;
 }
 
@@ -39,6 +44,7 @@ export interface Search_search_item_Video {
   id: string;
   title: string;
   description: string;
+  category: Search_search_item_Video_category;
   views: number;
   duration: number;
   thumbnailURL: string;
@@ -51,8 +57,8 @@ export interface Search_search_item_Channel {
   __typename: "Channel";
   id: string;
   handle: string;
-  avatarPhotoURL: string;
-  coverPhotoURL: string;
+  avatarPhotoURL: string | null;
+  coverPhotoURL: string | null;
   totalViews: number;
 }
 

+ 7 - 1
packages/app/src/api/queries/__generated__/VideoFields.ts

@@ -7,6 +7,11 @@
 // GraphQL fragment: VideoFields
 // ====================================================
 
+export interface VideoFields_category {
+  __typename: "Category";
+  id: string;
+}
+
 export interface VideoFields_media_location_HTTPVideoMediaLocation {
   __typename: "HTTPVideoMediaLocation";
   host: string;
@@ -30,7 +35,7 @@ export interface VideoFields_media {
 export interface VideoFields_channel {
   __typename: "Channel";
   id: string;
-  avatarPhotoURL: string;
+  avatarPhotoURL: string | null;
   handle: string;
 }
 
@@ -39,6 +44,7 @@ export interface VideoFields {
   id: string;
   title: string;
   description: string;
+  category: VideoFields_category;
   views: number;
   duration: number;
   thumbnailURL: string;

+ 0 - 24
packages/app/src/api/queries/__generated__/channel.ts

@@ -1,24 +0,0 @@
-/* tslint:disable */
-/* eslint-disable */
-// @generated
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: channel
-// ====================================================
-
-export interface channel_channel {
-  __typename: "Channel";
-  id: string;
-  handle: string;
-  avatarPhotoURL: string;
-  totalViews: number;
-}
-
-export interface channel {
-  channel: channel_channel | null;
-}
-
-export interface channelVariables {
-  id: string;
-}

+ 17 - 0
packages/app/src/api/queries/categories.ts

@@ -0,0 +1,17 @@
+import gql from 'graphql-tag'
+
+const categoriesFieldsFragment = gql`
+  fragment CategoryFields on Category {
+    id
+    name
+  }
+`
+
+export const GET_CATEGORIES = gql`
+  query GetCategories {
+    categories {
+      ...CategoryFields
+    }
+  }
+  ${categoriesFieldsFragment}
+`

+ 1 - 0
packages/app/src/api/queries/index.ts

@@ -1,3 +1,4 @@
 export * from './videos'
 export * from './channels'
 export * from './search'
+export * from './categories'

+ 6 - 4
packages/app/src/api/queries/videos.ts

@@ -5,6 +5,9 @@ export const videoFieldsFragment = gql`
     id
     title
     description
+    category {
+      id
+    }
     views
     duration
     thumbnailURL
@@ -30,10 +33,9 @@ export const videoFieldsFragment = gql`
   }
 `
 
-// TODO: Add proper query params (order, limit, etc.)
-export const GET_NEWEST_VIDEOS = gql`
-  query GetNewestVideos($offset: Int, $limit: Int) {
-    videos(offset: $offset, limit: $limit) {
+export const GET_VIDEOS = gql`
+  query GetVideos($offset: Int, $limit: Int, $categoryId: ID) {
+    videos(offset: $offset, limit: $limit, where: { categoryId_eq: $categoryId }) {
       ...VideoFields
     }
   }

+ 6 - 0
packages/app/src/components/Hero.tsx

@@ -2,6 +2,9 @@ import React from 'react'
 import { fluidRange } from 'polished'
 import { css } from '@emotion/core'
 import { Button, Header } from '@/shared/components'
+import { navigate } from '@reach/router'
+import sizes from '@/shared/theme/sizes'
+import routes from '@/config/routes'
 
 type HeroProps = {
   backgroundImg: string
@@ -20,6 +23,7 @@ const Hero: React.FC<Partial<HeroProps>> = ({ backgroundImg }) => {
           ${fluidRange({ prop: 'fontSize', fromSize: '40px', toSize: '72px' })};
           line-height: 0.94;
         }
+        margin: 0 -${sizes.b8}px;
       `}
     >
       <div
@@ -43,6 +47,8 @@ const Hero: React.FC<Partial<HeroProps>> = ({ backgroundImg }) => {
           containerCss={css`
             width: 96px;
           `}
+          // FIXME: remove after rebasing on navbar
+          onClick={() => navigate(routes.browse())}
         >
           Share
         </Button>

+ 12 - 4
packages/app/src/components/LayoutWithRouting.tsx

@@ -1,12 +1,15 @@
 import React from 'react'
+import styled from '@emotion/styled'
+import { Router } from '@reach/router'
+
 import { GlobalStyle } from '@/shared/components'
 import { Navbar } from '@/components'
-import { HomeView, VideoView, SearchView, ChannelView } from '@/views'
+import { HomeView, VideoView, SearchView, ChannelView, BrowseView } from '@/views'
 import routes from '@/config/routes'
-import { Router } from '@reach/router'
+import { sizes } from '@/shared/theme'
 
 const LayoutWithRouting: React.FC = () => (
-  <main>
+  <MainContainer>
     <GlobalStyle />
     <Router primary>
       <Navbar default />
@@ -15,9 +18,14 @@ const LayoutWithRouting: React.FC = () => (
       <HomeView default />
       <VideoView path={routes.video()} />
       <SearchView path={routes.search()} />
+      <BrowseView path={routes.browse()} />
       <ChannelView path={routes.channel()} />
     </Router>
-  </main>
+  </MainContainer>
 )
 
+const MainContainer = styled.main`
+  padding: 0 ${sizes.b8}px;
+`
+
 export default LayoutWithRouting

+ 0 - 21
packages/app/src/components/Main.tsx

@@ -1,21 +0,0 @@
-import React from 'react'
-import { css, SerializedStyles } from '@emotion/core'
-
-type MainProps = {
-  children: React.ReactNode
-  containerCss: SerializedStyles
-}
-const Main: React.FC<Partial<MainProps>> = ({ children, containerCss }) => (
-  <main
-    css={[
-      css`
-        padding: 0 2rem;
-      `,
-      containerCss,
-    ]}
-  >
-    {children}
-  </main>
-)
-
-export default Main

+ 0 - 36
packages/app/src/components/TagsGallery.tsx

@@ -1,36 +0,0 @@
-import React from 'react'
-import { Gallery, ToggleButton } from '@/shared/components'
-
-const tags = [
-  'finance',
-  'Sport',
-  'Health & Fitness',
-  'lifestyle',
-  'finance',
-  'Sport',
-  'Health & Fitness',
-  'lifestyle',
-  'finance',
-  'Sport',
-  'Health & Fitness',
-  'lifestyle',
-  'finance',
-  'Sport',
-  'Health & Fitness',
-  'lifestyle',
-]
-
-type TagsProps = {
-  title: string
-  action: string
-}
-
-const TagsGallery: React.FC<Partial<TagsProps>> = ({ title, action }) => (
-  <Gallery title={title} action={action}>
-    {tags.map((tag) => (
-      <ToggleButton key={tag}>{tag}</ToggleButton>
-    ))}
-  </Gallery>
-)
-
-export default TagsGallery

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

@@ -1,8 +1,6 @@
 export { default as LayoutWithRouting } from './LayoutWithRouting'
 export { default as VideoGallery } from './VideoGallery'
 export { default as Hero } from './Hero'
-export { default as TagsGallery } from './TagsGallery'
-export { default as Main } from './Main'
 export { default as SeriesGallery } from './SeriesGallery'
 export { default as ChannelGallery } from './ChannelGallery'
 export { default as Navbar } from './Navbar'

+ 1 - 0
packages/app/src/config/routes.ts

@@ -2,4 +2,5 @@ export default {
   video: (id = ':id') => `/video/${id}`,
   search: (searchStr = ':search') => `/search/${searchStr}`,
   channel: (id = ':id') => `/channel/${id}`,
+  browse: () => 'browse',
 }

+ 1 - 0
packages/app/src/mocking/data/index.ts

@@ -1,2 +1,3 @@
 export { default as mockVideos } from './mockVideos'
 export { default as mockChannels } from './mockChannels'
+export { default as mockCategories } from './mockCategories'

+ 26 - 0
packages/app/src/mocking/data/mockCategories.ts

@@ -0,0 +1,26 @@
+import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
+
+type RawCategory = Omit<CategoryFields, '__typename'>
+
+const rawCategories: RawCategory[] = [
+  {
+    id: 'db931957-f905-4c40-b708-85fcabcba4f9',
+    name: 'Sport',
+  },
+  {
+    id: 'a8f16c74-7040-4c61-afef-ca70b90b0c03',
+    name: 'Health & Fitness',
+  },
+  {
+    id: '1429bd8e-b5d9-426a-9090-83002ca2ea9e',
+    name: 'Lifestyle',
+  },
+  {
+    id: '02c287dc-0b35-41f8-a494-9d98e312fbff',
+    name: 'Business',
+  },
+]
+
+const mockCategories: CategoryFields[] = rawCategories.map((c) => ({ ...c, __typename: 'Category' }))
+
+export default mockCategories

+ 1 - 1
packages/app/src/mocking/data/mockVideos.ts

@@ -95,7 +95,7 @@ const rawVideos = [
   },
 ]
 
-type RawVideo = Omit<VideoFields, 'media' | 'channel' | 'publishedOnJoystreamAt' | 'duration'> & {
+type RawVideo = Omit<VideoFields, 'media' | 'category' | 'channel' | 'publishedOnJoystreamAt' | 'duration'> & {
   publishedOnJoystreamAt: unknown
   duration: unknown
 }

+ 25 - 13
packages/app/src/mocking/server.ts

@@ -3,8 +3,7 @@ import { createGraphQLHandler, mirageGraphQLFieldResolver } from '@miragejs/grap
 import { shuffle } from 'lodash'
 
 import schema from '../schema.graphql'
-import { mockChannels, mockVideos } from '@/mocking/data'
-import { GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
+import { mockCategories, mockChannels, mockVideos } from '@/mocking/data'
 import { SearchVariables } from '@/api/queries/__generated__/Search'
 
 createServer({
@@ -12,23 +11,22 @@ createServer({
     const graphQLHandler = createGraphQLHandler(schema, this.schema, {
       resolvers: {
         Query: {
-          videos: (obj: unknown, args: GetNewestVideosVariables, context: unknown, info: unknown) => {
-            const videos = mirageGraphQLFieldResolver(obj, {}, context, info)
+          videos: (obj: unknown, { limit, offset, where: { categoryId_eq } }: any, context: unknown, info: unknown) => {
+            const resolverArgs = categoryId_eq ? { categoryId: categoryId_eq } : {}
+            const videos = mirageGraphQLFieldResolver(obj, resolverArgs, context, info)
 
-            const { limit } = args
-            if (!limit) {
+            if (!limit && !offset) {
               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
+            const start = offset || 0
+            const end = limit ? start + limit : videos.length
+
+            return videos.slice(start, end)
           },
           featured_videos: (...params: unknown[]) => {
             const videos = mirageGraphQLFieldResolver(...params)
-            return shuffle(videos)
+            return shuffle(videos.slice(0, 16))
           },
           channels: (...params: unknown[]) => {
             const channels = mirageGraphQLFieldResolver(...params)
@@ -75,6 +73,13 @@ createServer({
       return models
     })
 
+    const categories = mockCategories.map((category) => {
+      const models = server.create('Category', {
+        ...category,
+      })
+      return models
+    })
+
     const location = server.schema.create('HTTPVideoMediaLocation', {
       id: 'locationID',
       host: 'https://js-video-example.s3.eu-central-1.amazonaws.com/waves.mp4',
@@ -89,11 +94,18 @@ createServer({
       location,
     })
 
-    mockVideos.forEach((video, idx) =>
+    // repeat videos 15 times
+    // TODO: expand as part of https://github.com/Joystream/joystream/issues/1270
+    const fakedMockVideos = Array.from({ length: 15 }, (_, idx) =>
+      mockVideos.map((v) => ({ ...v, id: `${v.id}${idx}` }))
+    ).flat()
+
+    fakedMockVideos.forEach((video, idx) =>
       server.create('Video', {
         ...video,
         media,
         channel: channels[idx % channels.length],
+        category: categories[idx % categories.length],
       })
     )
   },

+ 23 - 17
packages/app/src/schema.graphql

@@ -8,6 +8,11 @@ enum Language {
   French
 }
 
+type Member {
+  id: ID!
+  handle: String!
+}
+
 type Channel {
   id: ID!
 
@@ -18,9 +23,11 @@ type Channel {
 
   description: String!
 
-  coverPhotoURL: String!
+  coverPhotoURL: String
+
+  avatarPhotoURL: String
 
-  avatarPhotoURL: String!
+  owner: Member!
 
   isPublic: Boolean!
 
@@ -49,6 +56,7 @@ type JoystreamVideoMediaLocation {
 }
 
 type HTTPVideoMediaLocation {
+  # TODO: join these fields together
   host: String!
   port: Int
 }
@@ -159,18 +167,23 @@ type FreeTextSearchResult {
   rank: Int!
 }
 
+input ChannelWhereInput {
+  isCurated_eq: Boolean
+  isPublic_eq: Boolean
+}
+
+input VideoWhereInput {
+  categoryId_eq: ID
+  isCurated_eq: Boolean
+  isPublic_eq: Boolean
+}
+
 type Query {
   # Lookup a channel by its ID
   channel(id: ID!): Channel
 
   # List all channel by given constraints
-  channels(
-    order_by_creation_date: Boolean
-    ignore_curated: Boolean
-    ignore_non_public: Boolean
-    offset: Int
-    limit: Int
-  ): [Channel!]!
+  channels(order_by_creation_date: Boolean, where: ChannelWhereInput, offset: Int, limit: Int): [Channel!]!
 
   # Lookup a channel by its ID
   category(id: ID!): Category
@@ -182,14 +195,7 @@ type Query {
   video(id: ID!): Video
 
   # List all videos by given constraints
-  videos(
-    order_by_publication_date: Boolean
-    ignore_curated: Boolean
-    ignore_non_public: Boolean
-    in_category_with_ID: ID
-    offset: Int
-    limit: Int
-  ): [Video!]!
+  videos(order_by_publication_date: Boolean, where: VideoWhereInput, offset: Int, limit: Int): [Video!]!
 
   # List all top trending videos
   featured_videos: [Video!]!

+ 2 - 4
packages/app/src/shared/components/Button/Button.style.ts

@@ -1,16 +1,14 @@
-import React from 'react'
 import { css } from '@emotion/core'
 import styled from '@emotion/styled'
 import Icon from '../Icon'
-import { typography, colors } from '../../theme'
+import { colors, typography } from '../../theme'
 
 export type ButtonStyleProps = {
   variant?: 'primary' | 'secondary' | 'tertiary'
   full?: boolean
   size?: 'regular' | 'small' | 'smaller'
-  hasText?: boolean
-  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
   disabled?: boolean
+  hasText?: boolean
   clickable?: boolean
 }
 

+ 57 - 0
packages/app/src/shared/components/CategoryPicker/CategoryPicker.tsx

@@ -0,0 +1,57 @@
+import React from 'react'
+import styled from '@emotion/styled'
+import { Placeholder, ToggleButton } from '..'
+import sizes from '@/shared/theme/sizes'
+import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
+
+type CategoryPickerProps = {
+  categories?: CategoryFields[]
+  selectedCategoryId: string | null
+  loading?: boolean
+  onChange: (category: CategoryFields) => void
+  className?: string
+}
+
+const CATEGORY_PLACEHOLDER_WIDTHS = [80, 170, 120, 110, 80, 170, 120]
+
+const CategoryPicker: React.FC<CategoryPickerProps> = ({
+  categories,
+  selectedCategoryId,
+  loading,
+  onChange,
+  className,
+}) => {
+  const content =
+    !categories || loading
+      ? CATEGORY_PLACEHOLDER_WIDTHS.map((width, idx) => (
+          <StyledPlaceholder key={`placeholder-${idx}`} width={width} height="48px" />
+        ))
+      : categories.map((category) => (
+          <StyledToggleButton
+            key={category.id}
+            controlled
+            toggled={category.id === selectedCategoryId}
+            variant="secondary"
+            onClick={() => onChange(category)}
+          >
+            {category.name}
+          </StyledToggleButton>
+        ))
+
+  return <Container className={className}>{content}</Container>
+}
+
+const Container = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+`
+
+const StyledPlaceholder = styled(Placeholder)`
+  margin: 0 ${sizes.b3}px ${sizes.b3}px 0;
+`
+
+const StyledToggleButton = styled(ToggleButton)`
+  margin: 0 ${sizes.b3}px ${sizes.b3}px 0;
+`
+
+export default CategoryPicker

+ 3 - 0
packages/app/src/shared/components/CategoryPicker/index.ts

@@ -0,0 +1,3 @@
+import CategoryPicker from './CategoryPicker'
+
+export default CategoryPicker

+ 1 - 1
packages/app/src/shared/components/ChannelAvatar/ChannelAvatar.tsx

@@ -3,7 +3,7 @@ import { Container, Name, StyledAvatar } from './ChannelAvatar.style'
 
 type ChannelAvatarProps = {
   name: string
-  avatarUrl?: string
+  avatarUrl?: string | null
   className?: string
 }
 

+ 1 - 1
packages/app/src/shared/components/ChannelPreview/ChannelPreview.tsx

@@ -8,7 +8,7 @@ import { Avatar } from '..'
 type ChannelPreviewProps = {
   name: string
   views: number
-  avatarURL?: string
+  avatarURL?: string | null
   className?: string
   animated?: boolean
   onClick?: (e: React.MouseEvent<HTMLElement>) => void

+ 27 - 11
packages/app/src/shared/components/InfiniteVideoGrid/InfiniteVideoGrid.tsx

@@ -10,31 +10,47 @@ type InfiniteVideoGridProps = {
   title?: string
   videos?: VideoFields[]
   loadVideos: (offset: number, limit: number) => void
+  initialOffset?: number
+  initialLoading?: boolean
   className?: string
 }
 
-const INITIAL_ROWS = 2
-const VIDEOS_PER_ROW = 4
-
-const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({ title, videos, loadVideos, className }) => {
+export const INITIAL_ROWS = 4
+export const VIDEOS_PER_ROW = 4
+
+const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
+  title,
+  videos,
+  loadVideos,
+  initialOffset = 0,
+  initialLoading,
+  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 loadedVideosCount = videos?.length || 0
+  const videoRowsCount = Math.floor(loadedVideosCount / videosPerRow)
+  const initialRows = Math.max(videoRowsCount, INITIAL_ROWS)
+
+  const [currentRowsCount, setCurrentRowsCount] = useState(initialRows)
 
   const targetVideosCount = currentRowsCount * videosPerRow
-  const loadedVideosCount = videos?.length || 0
 
   useEffect(() => {
+    if (initialLoading) {
+      return
+    }
+
     if (targetVideosCount > loadedVideosCount) {
+      const offset = initialOffset + loadedVideosCount
       const missingVideosCount = targetVideosCount - loadedVideosCount
-      loadVideos(loadedVideosCount, missingVideosCount)
+      loadVideos(offset, 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])
+  }, [initialOffset, initialLoading, loadedVideosCount, targetVideosCount, loadVideos])
 
-  const videoRowsCount = Math.floor(loadedVideosCount / videosPerRow)
   const displayedVideos = videos?.slice(0, videoRowsCount * videosPerRow) || []
   const placeholderRowsCount = currentRowsCount - videoRowsCount
   const placeholdersCount = placeholderRowsCount * videosPerRow
@@ -56,14 +72,14 @@ const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({ title, videos, lo
 
   const gridContent = (
     <>
-      {displayedVideos.map((v, idx) => (
+      {displayedVideos.map((v) => (
         <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
+          key={v.id}
         />
       ))}
       {Array.from({ length: placeholdersCount }, (_, idx) => (

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

@@ -1,2 +1,2 @@
-import InfiniteVideoGrid from './InfiniteVideoGrid'
-export default InfiniteVideoGrid
+import InfiniteVideoGrid, { INITIAL_ROWS, VIDEOS_PER_ROW } from './InfiniteVideoGrid'
+export { InfiniteVideoGrid as default, INITIAL_ROWS, VIDEOS_PER_ROW }

+ 6 - 4
packages/app/src/shared/components/Placeholder/Placeholder.tsx

@@ -2,14 +2,16 @@ import styled from '@emotion/styled'
 import { colors } from '@/shared/theme'
 
 type PlaceholderProps = {
-  width?: string
-  height?: string
+  width?: string | number
+  height?: string | number
   rounded?: boolean
 }
 
+const getPropValue = (v: string | number) => (typeof v === 'string' ? v : `${v}px`)
+
 const Placeholder = styled.div<PlaceholderProps>`
-  width: ${({ width = '100%' }) => width};
-  height: ${({ height = '100%' }) => height};
+  width: ${({ width = '100%' }) => getPropValue(width)};
+  height: ${({ height = '100%' }) => getPropValue(height)};
   border-radius: ${({ rounded = false }) => (rounded ? '100%' : '0')};
   background-color: ${colors.gray['400']};
 `

+ 0 - 42
packages/app/src/shared/components/TagButton/TagButton.style.ts

@@ -1,42 +0,0 @@
-import { StyleFn, makeStyles } from './../../utils/style-reducer'
-import { typography, colors } from '../../theme'
-
-export type TagButtonStyleProps = {
-  selected?: boolean
-}
-
-const baseStyles: StyleFn = () => ({
-  border: `1px solid ${colors.blue[500]}`,
-  color: colors.white,
-  backgroundColor: colors.black,
-  textAlign: 'center',
-  padding: '15px 20px',
-  display: 'inline-block',
-  cursor: 'default',
-  fontFamily: typography.fonts.base,
-  fontWeight: typography.weights.medium,
-  fontSize: typography.sizes.button.large,
-  textTransform: 'capitalize',
-  whiteSpace: 'nowrap',
-  margin: '0 15px 0 0',
-  lineHeight: typography.sizes.button.large,
-
-  span: {
-    marginLeft: '20px',
-    fontSize: typography.sizes.icon.xxlarge,
-    fontWeight: typography.weights.regular,
-    lineHeight: 0,
-    verticalAlign: 'sub',
-  },
-
-  '&::selection': {
-    background: 'transparent',
-  },
-})
-
-const shadowFromProps: StyleFn = (styles, { selected = false }) => ({
-  ...styles,
-  boxShadow: selected ? `3px 3px ${colors.blue[500]}` : 'none',
-})
-
-export const useCSS = (props: TagButtonStyleProps) => makeStyles([baseStyles, shadowFromProps])(props)

+ 0 - 16
packages/app/src/shared/components/TagButton/TagButton.tsx

@@ -1,16 +0,0 @@
-import React from 'react'
-import { TagButtonStyleProps, useCSS } from './TagButton.style'
-
-type TagButtonProps = {
-  children: React.ReactNode
-  onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
-} & TagButtonStyleProps
-
-export default function TagButton({ children, onClick, ...styleProps }: TagButtonProps) {
-  const styles = useCSS(styleProps)
-  return (
-    <div css={styles} onClick={onClick}>
-      {children}
-    </div>
-  )
-}

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

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

+ 22 - 22
packages/app/src/shared/components/ToggleButton/ToggleButton.styles.tsx

@@ -6,11 +6,11 @@ import type { ButtonStyleProps } from '../Button/Button.style'
 import { spacing, colors } from '../../theme'
 
 export type ToggleButtonStyleProps = {
-  pressedDown: boolean
+  toggled: boolean
 } & ButtonStyleProps
 
-const hoverTransition = ({ pressedDown, disabled = false, variant }: ToggleButtonStyleProps) =>
-  !pressedDown && !disabled
+const hoverTransition = ({ toggled, disabled = false, variant }: ToggleButtonStyleProps) =>
+  !toggled && !disabled
     ? css`
         &:hover {
           transform: translate3d(-${spacing.xxs}, -${spacing.xxs}, 0);
@@ -21,8 +21,8 @@ const hoverTransition = ({ pressedDown, disabled = false, variant }: ToggleButto
       `
     : null
 
-const pressed = ({ pressedDown }: ToggleButtonStyleProps) =>
-  pressedDown
+const pressed = ({ toggled }: ToggleButtonStyleProps) =>
+  toggled
     ? css`
         border-color: ${colors.white};
         color: ${colors.white};
@@ -36,7 +36,7 @@ const pressed = ({ pressedDown }: ToggleButtonStyleProps) =>
       `
     : null
 
-const colorsFromProps = ({ variant, pressedDown }: ToggleButtonStyleProps) => {
+const colorsFromProps = ({ variant, toggled }: ToggleButtonStyleProps) => {
   let styles
   switch (variant) {
     case 'tertiary': {
@@ -56,16 +56,16 @@ const colorsFromProps = ({ variant, pressedDown }: ToggleButtonStyleProps) => {
     case 'secondary': {
       styles = css`
         color: ${colors.white};
-        background-color: ${pressedDown ? colors.blue[500] : colors.black};
-        border-color: ${pressedDown ? colors.white : colors.blue[500]};
+        background-color: ${toggled ? colors.blue[500] : colors.black};
+        border-color: ${toggled ? colors.white : colors.blue[500]};
         &:hover {
-          border-color: ${pressedDown ? colors.white : colors.blue[700]};
-          color: ${pressedDown ? colors.white : colors.blue[300]};
-          background-color: ${pressedDown ? colors.blue[700] : ''};
+          border-color: ${toggled ? colors.white : colors.blue[700]};
+          color: ${toggled ? colors.white : colors.blue[300]};
+          background-color: ${toggled ? colors.blue[700] : ''};
         }
         &:active {
-          border-color: ${pressedDown ? colors.white : colors.blue[700]};
-          color: ${pressedDown ? colors.white : colors.blue[700]};
+          border-color: ${toggled ? colors.white : colors.blue[700]};
+          color: ${toggled ? colors.white : colors.blue[700]};
         }
       `
       break
@@ -75,15 +75,15 @@ const colorsFromProps = ({ variant, pressedDown }: ToggleButtonStyleProps) => {
       styles = css`
         color: ${colors.white};
         background-color: ${colors.blue[500]};
-        border-color: ${pressedDown ? colors.white : colors.blue[500]};
+        border-color: ${toggled ? colors.white : colors.blue[500]};
         &:hover {
           background-color: ${colors.blue[700]};
-          border-color: ${pressedDown ? colors.white : colors.blue[700]};
+          border-color: ${toggled ? colors.white : colors.blue[700]};
           color: ${colors.white};
         }
         &:active {
           background-color: ${colors.blue[900]};
-          border-color: ${pressedDown ? colors.white : colors.blue[900]};
+          border-color: ${toggled ? colors.white : colors.blue[900]};
           color: ${colors.white};
         }
       `
@@ -116,9 +116,9 @@ const disabled = ({ disabled }: ToggleButtonStyleProps) =>
     : null
 
 export const StyledToggleButton = styled(Button)`
-     transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
-      ${colorsFromProps}
-      ${pressed}
-      ${hoverTransition}
-      ${disabled}
-      `
+  transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
+  ${colorsFromProps}
+  ${pressed}
+  ${hoverTransition}
+  ${disabled}
+`

+ 21 - 6
packages/app/src/shared/components/ToggleButton/ToggleButton.tsx

@@ -1,21 +1,36 @@
 import React, { useState } from 'react'
-import { StyledToggleButton, ToggleButtonStyleProps } from './ToggleButton.styles'
+import { StyledToggleButton } from './ToggleButton.styles'
 
 import type { ButtonProps } from '../Button/Button'
 
-type ToggleButtonProps = ButtonProps & Omit<ToggleButtonStyleProps, 'pressedDown'>
-const ToggleButton: React.FC<Partial<ToggleButtonProps>> = ({ onClick, children, ...buttonProps }) => {
-  const [pressedDown, setPressedDown] = useState(false)
+type ToggleButtonProps = {
+  controlled?: boolean
+  toggled?: boolean
+} & ButtonProps
+
+const ToggleButton: React.FC<Partial<ToggleButtonProps>> = ({
+  onClick,
+  controlled = false,
+  toggled: externalToggled = false,
+  children,
+  ...buttonProps
+}) => {
+  const [toggled, setToggled] = useState(false)
+
   const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
     if (onClick) {
       onClick(e)
     }
-    setPressedDown(!pressedDown)
+    if (!controlled) {
+      setToggled(!toggled)
+    }
   }
+
   return (
-    <StyledToggleButton onClick={handleClick} pressedDown={pressedDown} {...buttonProps}>
+    <StyledToggleButton onClick={handleClick} toggled={controlled ? externalToggled : toggled} {...buttonProps}>
       {children}
     </StyledToggleButton>
   )
 }
+
 export default ToggleButton

+ 88 - 116
packages/app/src/shared/components/Typography/Typography.style.ts

@@ -1,123 +1,95 @@
-import { typography, colors, spacing } from '../../theme'
+import { typography, colors } from '../../theme'
 
-export type TypographyStyleProps = {
-  variant:
-    | 'hero'
-    | 'h1'
-    | 'h2'
-    | 'h3'
-    | 'h4'
-    | 'h5'
-    | 'h6'
-    | 'subtitle1'
-    | 'subtitle2'
-    | 'body1'
-    | 'body2'
-    | 'caption'
-    | 'overhead'
+export type TypographyVariant =
+  | 'hero'
+  | 'h1'
+  | 'h2'
+  | 'h3'
+  | 'h4'
+  | 'h5'
+  | 'h6'
+  | 'subtitle1'
+  | 'subtitle2'
+  | 'body1'
+  | 'body2'
+  | 'caption'
+  | 'overhead'
+
+export const baseStyle = {
+  fontFamily: typography.fonts.base,
+  color: colors.white,
+  margin: 0,
 }
 
-export const makeStyles = ({ variant = 'body1' }: TypographyStyleProps) => {
-  const base = {
-    fontFamily: typography.fonts.base,
-    color: colors.white,
-  }
+export const variantStyles = {
+  hero: {
+    fontSize: typography.sizes.hero,
+    fontWeight: typography.weights.bold,
+    fontFamily: typography.fonts.headers,
+  },
+  h1: {
+    fontSize: typography.sizes.h1,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  h2: {
+    fontSize: typography.sizes.h2,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  h3: {
+    fontSize: typography.sizes.h3,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  h4: {
+    fontSize: typography.sizes.h4,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  h5: {
+    fontSize: typography.sizes.h5,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  h6: {
+    fontSize: typography.sizes.h6,
+    fontWeight: typography.weights.medium,
+    fontFamily: typography.fonts.headers,
+  },
+
+  subtitle1: {
+    fontSize: typography.sizes.subtitle1,
+    fontWeight: typography.weights.light,
+  },
+
+  subtitle2: {
+    fontSize: typography.sizes.subtitle2,
+    fontWeight: typography.weights.regular,
+  },
+
+  body1: {
+    fontSize: typography.sizes.body1,
+    fontWeight: typography.weights.light,
+  },
 
-  let specific = {}
+  body2: {
+    fontSize: typography.sizes.body2,
+    fontWeight: typography.weights.light,
+  },
 
-  switch (variant) {
-    case 'hero':
-      specific = {
-        fontSize: typography.sizes.hero,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.xxxl} 0`,
-      }
-      break
-    case 'h1':
-      specific = {
-        fontSize: typography.sizes.h1,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.xxl} 0`,
-      }
-      break
-    case 'h2':
-      specific = {
-        fontSize: typography.sizes.h2,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.xl} 0`,
-      }
-      break
-    case 'h3':
-      specific = {
-        fontSize: typography.sizes.h3,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.l} 0`,
-      }
-      break
-    case 'h4':
-      specific = {
-        fontSize: typography.sizes.h4,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.l} 0`,
-      }
-      break
-    case 'h5':
-      specific = {
-        fontSize: typography.sizes.h5,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.m} 0`,
-      }
-      break
-    case 'h6':
-      specific = {
-        fontSize: typography.sizes.h6,
-        fontWeight: typography.weights.medium,
-        margin: `${spacing.m} 0`,
-      }
-      break
-    case 'subtitle1':
-      specific = {
-        fontSize: typography.sizes.subtitle1,
-        fontWeight: typography.weights.light,
-        margin: `${spacing.l} 0`,
-      }
-      break
-    case 'subtitle2':
-      specific = {
-        fontSize: typography.sizes.subtitle2,
-        fontWeight: typography.weights.regular,
-        margin: `${spacing.m} 0`,
-      }
-      break
-    case 'body1':
-      specific = {
-        fontSize: typography.sizes.body1,
-        fontWeight: typography.weights.light,
-        margin: `${spacing.s} 0`,
-      }
-      break
-    case 'body2':
-      specific = {
-        fontSize: typography.sizes.body2,
-        fontWeight: typography.weights.light,
-        margin: `${spacing.xs} 0`,
-      }
-      break
-    case 'caption':
-      specific = {
-        fontSize: typography.sizes.caption,
-        fontWeight: typography.weights.light,
-        margin: `${spacing.xs} 0`,
-      }
-      break
-    case 'overhead':
-      specific = {
-        fontSize: typography.sizes.overhead,
-        fontWeight: typography.weights.regular,
-        margin: `${spacing.xs} 0`,
-      }
-      break
-  }
+  caption: {
+    fontSize: typography.sizes.caption,
+    fontWeight: typography.weights.light,
+  },
 
-  return { ...base, ...specific }
+  overhead: {
+    fontSize: typography.sizes.overhead,
+    fontWeight: typography.weights.regular,
+  },
 }

+ 26 - 8
packages/app/src/shared/components/Typography/Typography.tsx

@@ -1,16 +1,34 @@
 import React from 'react'
-import { makeStyles, TypographyStyleProps } from './Typography.style'
+import { baseStyle, variantStyles, TypographyVariant } from './Typography.style'
 
 type TypographyProps = {
-  children: React.ReactNode
-  onClick?: (e: React.MouseEvent<any>) => void
-} & TypographyStyleProps
+  variant: TypographyVariant
+  className?: string
+}
+
+const variantToTag: Record<TypographyVariant, keyof JSX.IntrinsicElements> = {
+  body1: 'p',
+  body2: 'p',
+  caption: 'caption',
+  overhead: 'span',
+  subtitle1: 'span',
+  subtitle2: 'span',
+  hero: 'h1',
+  h1: 'h1',
+  h2: 'h2',
+  h3: 'h3',
+  h4: 'h4',
+  h5: 'h5',
+  h6: 'h6',
+}
 
-export default function Typography({ children, onClick, ...styleProps }: TypographyProps) {
-  const styles = makeStyles(styleProps)
+const Typography: React.FC<TypographyProps> = ({ variant, className, children }) => {
+  const Tag = variantToTag[variant]
   return (
-    <div css={styles} onClick={onClick}>
+    <Tag css={[baseStyle, variantStyles[variant]]} className={className}>
       {children}
-    </div>
+    </Tag>
   )
 }
+
+export default Typography

+ 0 - 1
packages/app/src/shared/components/VideoPlayer/videoJsPlayer.ts

@@ -5,7 +5,6 @@ import 'video.js/dist/video-js.css'
 import { VideoFields_media_location } from '@/api/queries/__generated__/VideoFields'
 
 export type VideoJsConfig = {
-  // eslint-disable-next-line camelcase
   src: VideoFields_media_location
   width?: number
   height?: number

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

@@ -7,7 +7,6 @@ export { default as Link } from './Link'
 export { default as NavButton } from './NavButton'
 export { default as RadioButton } from './RadioButton'
 export { default as Checkbox } from './Checkbox'
-export { default as TagButton } from './TagButton'
 export { default as Tabs } from './Tabs'
 export { default as Tab } from './Tabs/Tab'
 export { default as Tag } from './Tag'
@@ -23,8 +22,9 @@ 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'
+export { default as InfiniteVideoGrid, INITIAL_ROWS, VIDEOS_PER_ROW } from './InfiniteVideoGrid'
 export { default as ToggleButton } from './ToggleButton'
 export { default as Icon } from './Icon'
 export { default as Searchbar } from './Searchbar'
 export { default as TabsMenu } from './TabsMenu'
+export { default as CategoryPicker } from './CategoryPicker'

+ 6 - 6
packages/app/src/shared/theme/typography.ts

@@ -4,14 +4,14 @@ export default {
     headers: "PxGroteskRegular, Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif",
   },
   weights: {
-    thin: '100',
-    light: '300',
-    regular: '400',
-    medium: '500',
-    bold: '700',
+    thin: 100,
+    light: 300,
+    regular: 400,
+    medium: 500,
+    bold: 700,
   },
   sizes: {
-    hero: '4rem',
+    hero: '4.5rem',
     h1: '3rem',
     h2: '2.5rem',
     h3: '2rem',

+ 86 - 0
packages/app/src/views/BrowseView.tsx

@@ -0,0 +1,86 @@
+import React, { useCallback, useState } from 'react'
+import styled from '@emotion/styled'
+import { RouteComponentProps } from '@reach/router'
+import { CategoryPicker, InfiniteVideoGrid, INITIAL_ROWS, Typography, VIDEOS_PER_ROW } from '@/shared/components'
+import { colors, sizes } from '@/shared/theme'
+import { useLazyQuery, useQuery } from '@apollo/client'
+import { GET_CATEGORIES, GET_VIDEOS } from '@/api/queries'
+import { GetCategories } from '@/api/queries/__generated__/GetCategories'
+import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
+import { GetVideos, GetVideosVariables } from '@/api/queries/__generated__/GetVideos'
+
+const BrowseView: React.FC<RouteComponentProps> = () => {
+  const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
+  const { loading: categoriesLoading, data: categoriesData } = useQuery<GetCategories>(GET_CATEGORIES, {
+    onCompleted: (data) => handleCategoryChange(data.categories[0]),
+  })
+  const [
+    loadVideos,
+    { data: videosData, fetchMore: fetchMoreVideos, refetch: refetchVideos, variables: videoQueryVariables },
+  ] = useLazyQuery<GetVideos, GetVideosVariables>(GET_VIDEOS, {
+    notifyOnNetworkStatusChange: true,
+    fetchPolicy: 'cache-and-network',
+  })
+
+  const handleCategoryChange = (category: CategoryFields) => {
+    setSelectedCategoryId(category.id)
+
+    // TODO: don't require this component to know the initial number of items
+    // I didn't have an idea on how to achieve that for now
+    // it will need to be reworked in some part anyway during switching to relay pagination
+    const variables = { offset: 0, limit: INITIAL_ROWS * VIDEOS_PER_ROW, categoryId: category.id }
+
+    if (!selectedCategoryId) {
+      // first videos fetch
+      loadVideos({ variables })
+    } else if (refetchVideos) {
+      refetchVideos(variables)
+    }
+  }
+
+  const handleLoadVideos = useCallback(
+    (offset: number, limit: number) => {
+      if (!selectedCategoryId || !fetchMoreVideos) {
+        return
+      }
+
+      const variables = { offset, limit, categoryId: selectedCategoryId }
+      fetchMoreVideos({ variables })
+    },
+    [selectedCategoryId, fetchMoreVideos]
+  )
+
+  return (
+    <div>
+      <Header variant="hero">Browse</Header>
+      <Typography variant="h5">Topics that may interest you</Typography>
+      <StyledCategoryPicker
+        categories={categoriesData?.categories}
+        loading={categoriesLoading}
+        selectedCategoryId={selectedCategoryId}
+        onChange={handleCategoryChange}
+      />
+      <InfiniteVideoGrid
+        key={videoQueryVariables?.categoryId || ''}
+        loadVideos={handleLoadVideos}
+        videos={videosData?.videos}
+        initialLoading={categoriesLoading}
+      />
+    </div>
+  )
+}
+
+const Header = styled(Typography)`
+  margin: ${sizes.b1 * 14}px 0 ${sizes.b10}px 0; // 56px 40px
+`
+
+const StyledCategoryPicker = styled(CategoryPicker)`
+  z-index: 10;
+  position: sticky;
+  top: 0;
+  padding-top: ${sizes.b5}px;
+  padding-bottom: ${sizes.b2}px;
+  background-color: ${colors.black};
+`
+
+export default BrowseView

+ 1 - 1
packages/app/src/views/ChannelView/ChannelView.style.tsx

@@ -3,7 +3,7 @@ import { Avatar } from '@/shared/components'
 import theme from '@/shared/theme'
 
 type ChannelHeaderProps = {
-  coverPhotoURL: string
+  coverPhotoURL: string | null
 }
 export const Header = styled.section<ChannelHeaderProps>`
   background-image: linear-gradient(0deg, #000000 10.85%, rgba(0, 0, 0, 0) 88.35%),

+ 33 - 27
packages/app/src/views/HomeView.tsx

@@ -1,50 +1,44 @@
 import React, { useCallback } from 'react'
-import { css } from '@emotion/core'
 import styled from '@emotion/styled'
-import { ChannelGallery, Hero, Main, VideoGallery } from '@/components'
+import { ChannelGallery, Hero, VideoGallery } from '@/components'
 import { RouteComponentProps } from '@reach/router'
-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 { useQuery } from '@apollo/client'
+import { GET_FEATURED_VIDEOS, GET_NEWEST_CHANNELS, GET_VIDEOS } from '@/api/queries'
 import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos'
 import { GetNewestChannels } from '@/api/queries/__generated__/GetNewestChannels'
+import { GetVideos, GetVideosVariables } from '@/api/queries/__generated__/GetVideos'
 import { InfiniteVideoGrid } from '@/shared/components'
 
 const backgroundImg = 'https://source.unsplash.com/Nyvq2juw4_o/1920x1080'
 
+const NEWEST_VIDEOS_COUNT = 8
+
 const HomeView: React.FC<RouteComponentProps> = () => {
-  const { loading: newestVideosLoading, data: newestVideosData } = useQuery<GetNewestVideos>(GET_NEWEST_VIDEOS)
+  const { loading: newestVideosLoading, data: videosData, fetchMore: fetchMoreVideos } = useQuery<
+    GetVideos,
+    GetVideosVariables
+  >(GET_VIDEOS, {
+    variables: { limit: 8, offset: 0 },
+  })
   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 newestVideos = videosData?.videos.slice(0, NEWEST_VIDEOS_COUNT)
+  const nextVideos = videosData?.videos.slice(NEWEST_VIDEOS_COUNT)
 
   const loadVideos = useCallback(
     (offset: number, limit: number) => {
       const variables = { offset, limit }
-      if (!fetchMoreNextVideos) {
-        getNextVideos({ variables })
-      } else {
-        fetchMoreNextVideos({ variables })
-      }
+      fetchMoreVideos({ variables })
     },
-    [getNextVideos, fetchMoreNextVideos]
+    [fetchMoreVideos]
   )
 
   return (
     <>
       <Hero backgroundImg={backgroundImg} />
-      <Main
-        containerCss={css`
-          margin: 1rem 0;
-          & > * {
-            margin-bottom: 3rem;
-          }
-        `}
-      >
-        <VideoGallery title="Newest videos" loading={newestVideosLoading} videos={newestVideosData?.videos} />
+      <Container>
+        <VideoGallery title="Newest videos" loading={newestVideosLoading} videos={newestVideos} />
         <VideoGallery
           title="Featured videos"
           loading={featuredVideosLoading}
@@ -55,12 +49,24 @@ const HomeView: React.FC<RouteComponentProps> = () => {
           loading={newestChannelsLoading}
           channels={newestChannelsData?.channels}
         />
-        <StyledInfiniteVideoGrid title="More videos" videos={nextVideosData?.videos} loadVideos={loadVideos} />
-      </Main>
+        <StyledInfiniteVideoGrid
+          title="More videos"
+          videos={nextVideos}
+          loadVideos={loadVideos}
+          initialOffset={NEWEST_VIDEOS_COUNT}
+        />
+      </Container>
     </>
   )
 }
 
+const Container = styled.div`
+  margin: 1rem 0;
+  & > * {
+    margin-bottom: 3rem;
+  }
+`
+
 const StyledInfiniteVideoGrid = styled(InfiniteVideoGrid)`
   margin: 0;
   padding-bottom: 4rem;

+ 10 - 11
packages/app/src/views/SearchView.tsx

@@ -1,5 +1,4 @@
 import React, { useState, useMemo } from 'react'
-import { css } from '@emotion/core'
 import styled from '@emotion/styled'
 import { spacing, typography, sizes } from '@/shared/theme'
 import { RouteComponentProps, navigate } from '@reach/router'
@@ -8,7 +7,7 @@ import { useQuery } from '@apollo/client'
 import { SEARCH } from '@/api/queries'
 import { Search, SearchVariables } from '@/api/queries/__generated__/Search'
 import { TabsMenu } from '@/shared/components'
-import { Main, VideoGrid, ChannelGallery, VideoBestMatch } from '@/components'
+import { VideoGrid, ChannelGallery, VideoBestMatch } from '@/components'
 import routes from '@/config/routes'
 
 type SearchViewProps = {
@@ -45,14 +44,7 @@ const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
 
   const [bestMatch, ...videos] = allVideos
   return (
-    <Main
-      containerCss={css`
-        margin: ${sizes.b4} 0;
-        & > * {
-          margin-bottom: ${sizes.b12}px;
-        }
-      `}
-    >
+    <Container>
       <TabsMenu tabs={tabs} onSelectTab={setSelectedIndex} initialIndex={0} />
       {bestMatch && <VideoBestMatch video={bestMatch} onClick={() => navigate(routes.video(bestMatch.id))} />}
       {videos.length > 0 && (selectedIndex === 0 || selectedIndex === 1) && (
@@ -64,8 +56,15 @@ const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
       {channels.length > 0 && (selectedIndex === 0 || selectedIndex === 2) && (
         <ChannelGallery title="Channels" action="See all" loading={loading} channels={channels} />
       )}
-    </Main>
+    </Container>
   )
 }
 
+const Container = styled.div`
+  margin: ${sizes.b4} 0;
+  & > * {
+    margin-bottom: ${sizes.b12}px;
+  }
+`
+
 export default SearchView

+ 1 - 1
packages/app/src/views/VideoView/VideoView.style.tsx

@@ -15,7 +15,7 @@ export const PlayerContainer = styled.div`
 `
 
 export const InfoContainer = styled.div`
-  padding: ${theme.spacing.xxl};
+  padding: ${theme.spacing.xxl} 0;
 `
 
 export const TitleActionsContainer = styled.div`

+ 3 - 1
packages/app/src/views/index.ts

@@ -2,4 +2,6 @@ import HomeView from './HomeView'
 import VideoView from './VideoView'
 import SearchView from './SearchView'
 import ChannelView from './ChannelView'
-export { HomeView, VideoView, SearchView, ChannelView }
+import BrowseView from './BrowseView'
+
+export { HomeView, VideoView, SearchView, ChannelView, BrowseView }