Преглед на файлове

merge dev - video content pages, asset metrics, fixes

merge dev - video content pages, asset metrics, fixes
Klaudiusz Dembler преди 3 години
родител
ревизия
7024d18632
променени са 100 файла, в които са добавени 2883 реда и са изтрити 1052 реда
  1. 4 0
      .env
  2. 0 1
      .gitignore
  3. 3 4
      package.json
  4. 19 11
      src/App.tsx
  5. 8 15
      src/MainLayout.tsx
  6. 14 2
      src/api/client/cache.ts
  7. 6 6
      src/api/client/resolvers.ts
  8. 167 0
      src/api/hooks/channel.ts
  9. 53 0
      src/api/hooks/video.ts
  10. 9 3
      src/api/hooks/workers.ts
  11. 48 5
      src/api/queries/__generated__/baseTypes.generated.ts
  12. 305 56
      src/api/queries/__generated__/channels.generated.tsx
  13. 118 2
      src/api/queries/__generated__/videos.generated.tsx
  14. 46 11
      src/api/queries/channels.graphql
  15. 16 2
      src/api/queries/videos.graphql
  16. 7 5
      src/api/schemas/extendedQueryNode.graphql
  17. 31 1
      src/api/schemas/orion.graphql
  18. 18 16
      src/components/ChannelCard.tsx
  19. 48 23
      src/components/ChannelGallery.tsx
  20. 3 9
      src/components/ChannelLink/ChannelLink.style.ts
  21. 14 4
      src/components/ChannelLink/ChannelLink.tsx
  22. 43 0
      src/components/ChannelWithVideos/ChannelWithVideos.style.ts
  23. 108 0
      src/components/ChannelWithVideos/ChannelWithVideos.tsx
  24. 1 0
      src/components/ChannelWithVideos/index.ts
  25. 6 4
      src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx
  26. 2 2
      src/components/Dialogs/ImageCropDialog/cropper.ts
  27. 43 0
      src/components/DiscoverChannels.tsx
  28. 0 31
      src/components/ErrorFallback.tsx
  29. 6 4
      src/components/InfiniteGrids/InfiniteChannelGrid.tsx
  30. 166 0
      src/components/InfiniteGrids/InfiniteChannelWithVideosGrid.tsx
  31. 23 0
      src/components/InfiniteGrids/InfiniteGrid.style.ts
  32. 58 20
      src/components/InfiniteGrids/InfiniteVideoGrid.tsx
  33. 1 0
      src/components/InfiniteGrids/index.ts
  34. 55 11
      src/components/InfiniteGrids/useInfiniteGrid.ts
  35. 2 2
      src/components/InterruptedVideosGallery.tsx
  36. 6 3
      src/components/LimitedWidthContainer.tsx
  37. 23 0
      src/components/OfficialJoystreamUpdate.tsx
  38. 31 0
      src/components/PromisingNewChannels.tsx
  39. 11 6
      src/components/Sidenav/ViewerSidenav/ViewerSidenav.tsx
  40. 15 0
      src/components/TopTenChannels.tsx
  41. 14 0
      src/components/TopTenThisWeek.tsx
  42. 79 52
      src/components/VideoGallery.tsx
  43. 41 138
      src/components/VideoHero/VideoHero.style.ts
  44. 39 41
      src/components/VideoHero/VideoHero.tsx
  45. 11 7
      src/components/VideoHero/VideoHeroData.ts
  46. 3 2
      src/components/VideoTile.tsx
  47. 58 25
      src/components/ViewErrorFallback.tsx
  48. 6 3
      src/components/index.ts
  49. 2 1
      src/config/routes.ts
  50. 1 0
      src/config/urls.ts
  51. 1 0
      src/hooks/index.ts
  52. 31 0
      src/hooks/useHandleFollowChannel.tsx
  53. 6 13
      src/index.tsx
  54. 8 22
      src/joystream-lib/api.ts
  55. 9 9
      src/mocking/accessors/filtering.ts
  56. 1 0
      src/mocking/data/mockChannels.ts
  57. 2 2
      src/mocking/mutations.ts
  58. 3 3
      src/mocking/queries.ts
  59. 54 7
      src/providers/assets/assetsManager.tsx
  60. 45 8
      src/providers/assets/helpers.ts
  61. 5 1
      src/providers/editVideoSheet/hooks.tsx
  62. 2 2
      src/providers/joystream/provider.tsx
  63. 59 49
      src/providers/storageProviders.tsx
  64. 2 2
      src/providers/transactionManager/transactionManager.tsx
  65. 16 9
      src/providers/transactionManager/useTransaction.ts
  66. 13 6
      src/providers/uploadsManager/useStartFileUpload.tsx
  67. 38 27
      src/providers/user/store.ts
  68. 32 17
      src/providers/user/user.tsx
  69. 17 19
      src/shared/components/ActionBar/ActionBarTransaction.tsx
  70. 13 1
      src/shared/components/Avatar/Avatar.style.tsx
  71. 6 0
      src/shared/components/Button/Button.stories.tsx
  72. 1 1
      src/shared/components/Button/Button.tsx
  73. 2 0
      src/shared/components/ButtonBase/ButtonBase.style.ts
  74. 3 14
      src/shared/components/ButtonBase/ButtonBase.tsx
  75. 30 0
      src/shared/components/CallToActionButton/CallToActionButton.stories.tsx
  76. 79 0
      src/shared/components/CallToActionButton/CallToActionButton.style.ts
  77. 39 0
      src/shared/components/CallToActionButton/CallToActionButton.tsx
  78. 3 0
      src/shared/components/CallToActionButton/index.ts
  79. 4 2
      src/shared/components/Carousel/Carousel.stories.tsx
  80. 67 48
      src/shared/components/Carousel/Carousel.style.ts
  81. 102 42
      src/shared/components/Carousel/Carousel.tsx
  82. 0 27
      src/shared/components/ChannelCardBase/ChannelCard.stories.tsx
  83. 33 0
      src/shared/components/ChannelCardBase/ChannelCardBase.stories.tsx
  84. 63 0
      src/shared/components/ChannelCardBase/ChannelCardBase.style.ts
  85. 0 96
      src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx
  86. 47 66
      src/shared/components/ChannelCardBase/ChannelCardBase.tsx
  87. 1 2
      src/shared/components/ContextMenu/ContextMenu.style.ts
  88. 3 2
      src/shared/components/EmptyFallback/EmptyFallback.tsx
  89. 2 2
      src/shared/components/FormField/FormField.style.ts
  90. 12 14
      src/shared/components/Gallery/Gallery.style.ts
  91. 52 9
      src/shared/components/Gallery/Gallery.tsx
  92. 2 0
      src/shared/components/Glider/Glider.tsx
  93. 18 0
      src/shared/components/GridHeading/GridHeading.styles.ts
  94. 1 0
      src/shared/components/GridHeading/index.ts
  95. 2 1
      src/shared/components/HelperText/HelperText.style.ts
  96. 56 0
      src/shared/components/LayoutGrid/LayoutGrid.tsx
  97. 29 0
      src/shared/components/LoadMoreButton/LoadMoreButton.tsx
  98. 1 0
      src/shared/components/LoadMoreButton/index.ts
  99. 3 1
      src/shared/components/MultiFileSelect/MultiFileSelect.tsx
  100. 74 0
      src/shared/components/RankingNumberTile/RankingNumberTile.style.ts

+ 4 - 0
.env

@@ -8,15 +8,19 @@ REACT_APP_DEVELOPMENT_QUERY_NODE_SUBSCRIPTION_URL=wss://sumer-dev-2.joystream.ap
 REACT_APP_DEVELOPMENT_ORION_URL=https://sumer-dev-2.joystream.app/orion/graphql
 REACT_APP_DEVELOPMENT_NODE_URL=wss://sumer-dev-2.joystream.app/rpc
 REACT_APP_DEVELOPMENT_FAUCET_URL=https://sumer-dev-2.joystream.app/members/register
+REACT_APP_DEVELOPMENT_OFFICIAL_JOYSTREAM_CHANNEL_ID=12
 
 REACT_APP_PRODUCTION_QUERY_NODE_URL=https://hydra.joystream.org/graphql
 REACT_APP_PRODUCTION_QUERY_NODE_SUBSCRIPTION_URL=wss://hydra.joystream.org/graphql
 REACT_APP_PRODUCTION_ORION_URL=https://orion.joystream.org/graphql
+REACT_APP_PRODUCTION_ASSET_LOGS_URL=https://orion.joystream.org/logs
 REACT_APP_PRODUCTION_NODE_URL=wss://rome-rpc-endpoint.joystream.org:9944/
 REACT_APP_PRODUCTION_FAUCET_URL=https://member-faucet.joystream.org/register
+REACT_APP_PRODUCTION_OFFICIAL_JOYSTREAM_CHANNEL_ID=3
 
 REACT_APP_MOCKING_QUERY_NODE_URL=/mocked-query-node
 REACT_APP_MOCKING_QUERY_NODE_SUBSCRIPTION_URL=ws://127.0.0.1:9955
 REACT_APP_MOCKING_ORION_URL=/mocked-orion
 REACT_APP_MOCKING_NODE_URL=ws://127.0.0.1:9944
 REACT_APP_MOCKING_FAUCET_URL=/mocked-faucet
+REACT_APP_MOCKING_OFFICIAL_JOYSTREAM_CHANNEL_ID=1

+ 0 - 1
.gitignore

@@ -1,5 +1,4 @@
 # Logs
-logs
 *.log
 npm-debug.log*
 yarn-debug.log*

+ 3 - 4
package.json

@@ -21,7 +21,7 @@
     "dev": "yarn start",
     "build": "react-app-rewired build --stats && yarn bundle-stats",
     "eject": "react-app-rewired eject",
-    "lint:ts": "eslint --ext .js,.jsx,.ts,.tsx .",
+    "lint:ts": "eslint --ext .js,.jsx,.ts,.tsx --max-warnings 0 .",
     "lint:prettier": "prettier --check .",
     "lint:css": "stylelint './src/**/*.{tsx,ts}'",
     "lint": "yarn lint:css && yarn lint:ts && yarn lint:prettier",
@@ -52,8 +52,7 @@
     "@joystream/types": "~0.16.1",
     "@loadable/component": "^5.14.1",
     "@polkadot/extension-dapp": "~0.37.3-17",
-    "@sentry/integrations": "^6.3.5",
-    "@sentry/react": "^6.3.5",
+    "@sentry/react": "^6.11.0",
     "@tippyjs/react": "^4.2.5",
     "apollo": "^2.30.2",
     "awesome-debounce-promise": "^2.1.0",
@@ -65,7 +64,7 @@
     "date-fns": "^2.15.0",
     "downshift": "^6.1.0",
     "emotion-normalize": "~11.0.0",
-    "glider-js": "^1.7.3",
+    "glider-js": "^1.7.7",
     "graphql": "^15.3.0",
     "graphql-tag": "^2.11.0",
     "graphql-tools": "^7.0.2",

+ 19 - 11
src/App.tsx

@@ -1,7 +1,10 @@
 import { ApolloProvider } from '@apollo/client'
 import React from 'react'
+import { BrowserRouter } from 'react-router-dom'
 
 import { createApolloClient } from '@/api'
+import { GlobalStyle } from '@/shared/components'
+import { routingTransitions } from '@/styles/routingTransitions'
 
 import { MainLayout } from './MainLayout'
 import { AssetsManager, DialogProvider, OverlayManagerProvider, Snackbars, StorageProvidersProvider } from './providers'
@@ -12,16 +15,21 @@ export const App = () => {
   const apolloClient = createApolloClient()
 
   return (
-    <ApolloProvider client={apolloClient}>
-      <OverlayManagerProvider>
-        <StorageProvidersProvider>
-          <DialogProvider>
-            <MainLayout />
-            <Snackbars />
-            <AssetsManager />
-          </DialogProvider>
-        </StorageProvidersProvider>
-      </OverlayManagerProvider>
-    </ApolloProvider>
+    <>
+      <GlobalStyle additionalStyles={[routingTransitions]} />
+      <ApolloProvider client={apolloClient}>
+        <BrowserRouter>
+          <OverlayManagerProvider>
+            <StorageProvidersProvider>
+              <DialogProvider>
+                <MainLayout />
+                <Snackbars />
+                <AssetsManager />
+              </DialogProvider>
+            </StorageProvidersProvider>
+          </OverlayManagerProvider>
+        </BrowserRouter>
+      </ApolloProvider>
+    </>
   )
 }

+ 8 - 15
src/MainLayout.tsx

@@ -1,11 +1,9 @@
 import loadable from '@loadable/component'
 import React, { useEffect } from 'react'
-import { BrowserRouter, Route, Routes } from 'react-router-dom'
+import { Route, Routes } from 'react-router-dom'
 
 import { StudioLoading, TopbarBase } from '@/components'
 import { BASE_PATHS } from '@/config/routes'
-import { GlobalStyle } from '@/shared/components'
-import { routingTransitions } from '@/styles/routingTransitions'
 import { isBrowserOutdated } from '@/utils/browser'
 
 import { useDialog } from './providers'
@@ -43,17 +41,12 @@ export const MainLayout: React.FC = () => {
   }, [openDialog])
 
   return (
-    <>
-      <GlobalStyle additionalStyles={[routingTransitions]} />
-      <BrowserRouter>
-        <Routes>
-          <Route path={BASE_PATHS.viewer + '/*'} element={<ViewerLayout />} />
-          <Route path={BASE_PATHS.legal + '/*'} element={<LegalLayout />} />
-          <Route path={BASE_PATHS.studio + '/*'} element={<LoadableStudioLayout />} />
-          <Route path={BASE_PATHS.playground + '/*'} element={<PlaygroundLayout />} />
-          <Route path={BASE_PATHS.admin + '/*'} element={<AdminView />} />
-        </Routes>
-      </BrowserRouter>
-    </>
+    <Routes>
+      <Route path={BASE_PATHS.viewer + '/*'} element={<ViewerLayout />} />
+      <Route path={BASE_PATHS.legal + '/*'} element={<LegalLayout />} />
+      <Route path={BASE_PATHS.studio + '/*'} element={<LoadableStudioLayout />} />
+      <Route path={BASE_PATHS.playground + '/*'} element={<PlaygroundLayout />} />
+      <Route path={BASE_PATHS.admin + '/*'} element={<AdminView />} />
+    </Routes>
   )
 }

+ 14 - 2
src/api/client/cache.ts

@@ -7,6 +7,7 @@ import { parseISO } from 'date-fns'
 import {
   AllChannelFieldsFragment,
   AssetAvailability,
+  GetChannelsConnectionQueryVariables,
   GetVideosConnectionQueryVariables,
   Query,
   VideoConnection,
@@ -20,17 +21,28 @@ const getVideoKeyArgs = (args: GetVideosConnectionQueryVariables | null) => {
   const channelId = args?.where?.channelId_eq || ''
   const categoryId = args?.where?.categoryId_eq || ''
   const idEq = args?.where?.id_eq || ''
+  const idIn = args?.where?.id_in || []
   const isPublic = args?.where?.isPublic_eq ?? ''
   const channelIdIn = args?.where?.channelId_in ? JSON.stringify(args.where.channelId_in) : ''
   const createdAtGte = args?.where?.createdAt_gte ? JSON.stringify(args.where.createdAt_gte) : ''
   const sorting = args?.orderBy?.[0] ? args.orderBy[0] : ''
+  const isFeatured = args?.where?.isFeatured_eq ?? ''
 
   // only for counting videos in HomeView
   if (args?.where?.channelId_in && !args?.first) {
     return `${createdAtGte}:${channelIdIn}`
   }
 
-  return `${onlyCount}:${channelId}:${categoryId}:${channelIdIn}:${createdAtGte}:${isPublic}:${idEq}:${sorting}`
+  return `${onlyCount}:${channelId}:${categoryId}:${channelIdIn}:${createdAtGte}:${isPublic}:${idEq}:${idIn}:${sorting}:${isFeatured}`
+}
+
+const getChannelKeyArgs = (args: Record<string, GetChannelsConnectionQueryVariables['where']> | null) => {
+  // make sure queries asking for a specific category are separated in cache
+  const languageId = args?.where?.languageId_eq || ''
+  const idIn = args?.where?.id_in || []
+  const orderBy = args?.orderBy || []
+
+  return `${languageId}:${idIn}:${orderBy}`
 }
 
 const createDateHandler = () => ({
@@ -86,7 +98,7 @@ const createCachedAvailabilityHandler = () => ({
 type CachePolicyFields<T extends string> = Partial<Record<T, FieldPolicy | FieldReadFunction>>
 
 const queryCacheFields: CachePolicyFields<keyof Query> = {
-  channelsConnection: relayStylePagination(),
+  channelsConnection: relayStylePagination(getChannelKeyArgs),
   videosConnection: {
     ...relayStylePagination(getVideoKeyArgs),
     read(

+ 6 - 6
src/api/client/resolvers.ts

@@ -3,7 +3,7 @@ import type { IResolvers, ISchemaLevelResolver } from '@graphql-tools/utils'
 import { GraphQLSchema } from 'graphql'
 
 import { createLookup } from '@/utils/data'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import {
   ORION_BATCHED_FOLLOWS_QUERY_NAME,
@@ -84,7 +84,7 @@ export const queryNodeStitchingResolvers = (
         const viewsLookup = createLookup<{ id: string; views: number }>(batchedVideoViews || [])
         return videos.map((video: Video) => ({ ...video, views: viewsLookup[video.id]?.views || 0 }))
       } catch (error) {
-        Logger.warn('Failed to resolve videos field', { error })
+        ConsoleLogger.warn('Failed to resolve videos field', { error })
         return null
       }
     },
@@ -143,7 +143,7 @@ export const queryNodeStitchingResolvers = (
           views: viewsLookup[channel.id]?.views || 0,
         }))
       } catch (error) {
-        Logger.warn('Failed to resolve channels field', { error })
+        ConsoleLogger.warn('Failed to resolve channels field', { error })
         return null
       }
     },
@@ -179,7 +179,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve views field', { error })
+        ConsoleLogger.warn('Failed to resolve views field', { error })
         return null
       }
     },
@@ -235,7 +235,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve views field', { error })
+        ConsoleLogger.warn('Failed to resolve views field', { error })
         return null
       }
     },
@@ -259,7 +259,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve follows field', { error })
+        ConsoleLogger.warn('Failed to resolve follows field', { error })
         return null
       }
     },

+ 167 - 0
src/api/hooks/channel.ts

@@ -1,4 +1,5 @@
 import { MutationHookOptions, QueryHookOptions } from '@apollo/client'
+import { useMemo } from 'react'
 
 import {
   AssetAvailability,
@@ -7,12 +8,24 @@ import {
   GetChannelQuery,
   GetChannelsQuery,
   GetChannelsQueryVariables,
+  GetMostFollowedChannelsAllTimeQuery,
+  GetMostFollowedChannelsAllTimeQueryVariables,
+  GetMostFollowedChannelsQuery,
+  GetMostFollowedChannelsQueryVariables,
+  GetMostViewedChannelsAllTimeQuery,
+  GetMostViewedChannelsAllTimeQueryVariables,
+  GetMostViewedChannelsQuery,
+  GetMostViewedChannelsQueryVariables,
   GetVideoCountQuery,
   UnfollowChannelMutation,
   useFollowChannelMutation,
   useGetBasicChannelQuery,
   useGetChannelQuery,
   useGetChannelsQuery,
+  useGetMostFollowedChannelsAllTimeQuery,
+  useGetMostFollowedChannelsQuery,
+  useGetMostViewedChannelsAllTimeQuery,
+  useGetMostViewedChannelsQuery,
   useGetVideoCountQuery,
   useUnfollowChannelMutation,
 } from '@/api/queries'
@@ -122,3 +135,157 @@ export const useUnfollowChannel = (opts?: UnfollowChannelOpts) => {
     ...rest,
   }
 }
+
+type MostFollowedChannelsOpts = QueryHookOptions<GetMostFollowedChannelsQuery>
+export const useMostFollowedChannelsIds = (
+  variables?: GetMostFollowedChannelsQueryVariables,
+  opts?: MostFollowedChannelsOpts
+) => {
+  const { data, ...rest } = useGetMostFollowedChannelsQuery({ ...opts, variables })
+  return {
+    mostFollowedChannels: data?.mostFollowedChannels,
+    ...rest,
+  }
+}
+
+export const useMostFollowedChannels = (
+  variables?: GetMostFollowedChannelsQueryVariables,
+  opts?: MostFollowedChannelsOpts
+) => {
+  const { mostFollowedChannels } = useMostFollowedChannelsIds(variables, opts)
+
+  const mostFollowedChannelsIds = mostFollowedChannels?.map((item) => item.id)
+
+  const { channels, ...rest } = useChannels(
+    {
+      where: {
+        id_in: mostFollowedChannelsIds,
+      },
+    },
+    { skip: !mostFollowedChannelsIds }
+  )
+
+  return {
+    channels,
+    ...rest,
+  }
+}
+
+type MostViewedChannelsOpts = QueryHookOptions<GetMostViewedChannelsQuery>
+export const useMostViewedChannelsIds = (
+  variables?: GetMostViewedChannelsQueryVariables,
+  opts?: MostViewedChannelsOpts
+) => {
+  const { data, ...rest } = useGetMostViewedChannelsQuery({ ...opts, variables })
+  return {
+    mostViewedChannels: data?.mostViewedChannels,
+    ...rest,
+  }
+}
+
+export const useMostViewedChannels = (
+  variables?: GetMostViewedChannelsQueryVariables,
+  opts?: MostViewedChannelsOpts
+) => {
+  const { mostViewedChannels } = useMostViewedChannelsIds(variables, opts)
+
+  const mostViewedChannelsIds = mostViewedChannels?.map((item) => item.id)
+
+  const { channels, ...rest } = useChannels(
+    {
+      where: {
+        id_in: mostViewedChannelsIds,
+      },
+    },
+    { skip: !mostViewedChannelsIds }
+  )
+
+  const sortedChannels = useMemo(() => {
+    if (channels) {
+      return [...channels].sort((a, b) => (b.follows || 0) - (a.follows || 0))
+    }
+    return null
+  }, [channels])
+
+  return {
+    channels: sortedChannels,
+    ...rest,
+  }
+}
+
+type MostFollowedChannelsAllTimeOpts = QueryHookOptions<GetMostFollowedChannelsAllTimeQuery>
+export const useMostFollowedChannelsAllTimeIds = (
+  variables?: GetMostFollowedChannelsAllTimeQueryVariables,
+  opts?: MostFollowedChannelsAllTimeOpts
+) => {
+  const { data, ...rest } = useGetMostFollowedChannelsAllTimeQuery({ ...opts, variables })
+  return {
+    mostFollowedChannelsAllTime: data?.mostFollowedChannelsAllTime,
+    ...rest,
+  }
+}
+
+export const useMostFollowedChannelsAllTime = (
+  variables?: GetMostFollowedChannelsAllTimeQueryVariables,
+  opts?: MostFollowedChannelsAllTimeOpts
+) => {
+  const { mostFollowedChannelsAllTime } = useMostFollowedChannelsAllTimeIds(variables, opts)
+
+  const mostFollowedChannelsAllTimeIds = mostFollowedChannelsAllTime?.map((item) => item.id)
+
+  const { channels, ...rest } = useChannels(
+    {
+      where: {
+        id_in: mostFollowedChannelsAllTimeIds,
+      },
+    },
+    { skip: !mostFollowedChannelsAllTimeIds }
+  )
+
+  const sortedChannels = useMemo(() => {
+    if (channels) {
+      return [...channels].sort((a, b) => (b.follows || 0) - (a.follows || 0))
+    }
+    return null
+  }, [channels])
+
+  return {
+    channels: sortedChannels,
+    ...rest,
+  }
+}
+
+type MostViewedChannelsAllTimeOpts = QueryHookOptions<GetMostViewedChannelsAllTimeQuery>
+export const useMostViewedChannelsAllTimeIds = (
+  variables?: GetMostViewedChannelsAllTimeQueryVariables,
+  opts?: MostViewedChannelsAllTimeOpts
+) => {
+  const { data, ...rest } = useGetMostViewedChannelsAllTimeQuery({ ...opts, variables })
+  return {
+    mostViewedChannelsAllTime: data?.mostViewedChannelsAllTime,
+    ...rest,
+  }
+}
+
+export const useMostViewedChannelsAllTime = (
+  variables?: GetMostViewedChannelsAllTimeQueryVariables,
+  opts?: MostViewedChannelsAllTimeOpts
+) => {
+  const { mostViewedChannelsAllTime } = useMostViewedChannelsAllTimeIds(variables, opts)
+
+  const mostViewedChannelsIds = mostViewedChannelsAllTime?.map((item) => item.id)
+
+  const { channels, ...rest } = useChannels(
+    {
+      where: {
+        id_in: mostViewedChannelsIds,
+      },
+    },
+    { skip: !mostViewedChannelsIds }
+  )
+
+  return {
+    channels,
+    ...rest,
+  }
+}

+ 53 - 0
src/api/hooks/video.ts

@@ -4,11 +4,17 @@ import {
   AddVideoViewMutation,
   GetBasicVideosQuery,
   GetBasicVideosQueryVariables,
+  GetMostViewedVideosAllTimeQuery,
+  GetMostViewedVideosAllTimeQueryVariables,
+  GetMostViewedVideosQuery,
+  GetMostViewedVideosQueryVariables,
   GetVideoQuery,
   GetVideosQuery,
   GetVideosQueryVariables,
   useAddVideoViewMutation,
   useGetBasicVideosQuery,
+  useGetMostViewedVideosAllTimeQuery,
+  useGetMostViewedVideosQuery,
   useGetVideoQuery,
   useGetVideosQuery,
 } from '@/api/queries'
@@ -64,3 +70,50 @@ export const useBasicVideos = (variables?: GetBasicVideosQueryVariables, opts?:
     ...rest,
   }
 }
+
+type MostViewedVideosOpts = QueryHookOptions<GetMostViewedVideosQuery>
+export const useMostViewedVideosIds = (variables?: GetMostViewedVideosQueryVariables, opts?: MostViewedVideosOpts) => {
+  const { data, ...rest } = useGetMostViewedVideosQuery({ ...opts, variables })
+  return {
+    mostViewedVideos: data?.mostViewedVideos,
+    ...rest,
+  }
+}
+
+export const useMostViewedVideos = (variables?: GetMostViewedVideosQueryVariables, opts?: MostViewedVideosOpts) => {
+  const { mostViewedVideos, loading, error } = useMostViewedVideosIds(variables, opts)
+
+  const mostViewedVideosIds = mostViewedVideos?.map((item) => item.id)
+
+  const { videos, ...rest } = useVideos(
+    {
+      where: {
+        id_in: mostViewedVideosIds,
+      },
+    },
+    { skip: !mostViewedVideosIds }
+  )
+
+  const sortedVideos = videos
+    ? videos.map((video) => ({ ...video, views: video.views || 0 })).sort((a, b) => b.views - a.views)
+    : null
+
+  return {
+    videos: sortedVideos,
+    ...rest,
+    error: error || rest.error,
+    loading: loading || rest.loading,
+  }
+}
+
+type MostViewedVideosAllTimeOpts = QueryHookOptions<GetMostViewedVideosAllTimeQuery>
+export const useMostViewedVideosAllTimeIds = (
+  variables?: GetMostViewedVideosAllTimeQueryVariables,
+  opts?: MostViewedVideosAllTimeOpts
+) => {
+  const { data, ...rest } = useGetMostViewedVideosAllTimeQuery({ ...opts, variables })
+  return {
+    mostViewedVideosAllTime: data?.mostViewedVideosAllTime,
+    ...rest,
+  }
+}

+ 9 - 3
src/api/hooks/workers.ts

@@ -22,15 +22,21 @@ export const useWorker = (id: string, opts?: WorkerOpts) => {
 }
 
 type WorkersOpts = QueryHookOptions<GetWorkersQuery>
+export const storageWorkersVariables: GetWorkersQueryVariables = {
+  where: {
+    metadata_contains: 'http',
+    isActive_eq: true,
+    type_eq: WorkerType.Storage,
+  },
+}
 export const useStorageWorkers = (variables: GetWorkersQueryVariables, opts?: WorkersOpts) => {
   const { data, loading, ...rest } = useGetWorkersQuery({
     ...opts,
     variables: {
+      ...storageWorkersVariables,
       ...variables,
       where: {
-        metadata_contains: 'http',
-        isActive_eq: true,
-        type_eq: WorkerType.Storage,
+        ...storageWorkersVariables.where,
         ...variables.where,
       },
     },

+ 48 - 5
src/api/queries/__generated__/baseTypes.generated.ts

@@ -12,11 +12,6 @@ export type Scalars = {
   DateTime: Date
 }
 
-export type Language = {
-  __typename?: 'Language'
-  iso: Scalars['String']
-}
-
 export type VideoCategory = {
   __typename?: 'VideoCategory'
   id: Scalars['ID']
@@ -126,6 +121,7 @@ export type ChannelWhereInput = {
   isCensored_eq?: Maybe<Scalars['Boolean']>
   coverPhotoAvailability_eq?: Maybe<AssetAvailability>
   avatarPhotoAvailability_eq?: Maybe<AssetAvailability>
+  languageId_eq?: Maybe<Scalars['ID']>
 }
 
 export type ChannelWhereUniqueInput = {
@@ -259,6 +255,12 @@ export type ProcessorState = {
   chainHead: Scalars['Float']
 }
 
+export type Language = {
+  __typename?: 'Language'
+  id: Scalars['ID']
+  iso: Scalars['String']
+}
+
 export type Query = {
   __typename?: 'Query'
   /** Get follows counts for a list of channels */
@@ -276,6 +278,18 @@ export type Query = {
   channelsConnection: ChannelConnection
   membershipByUniqueInput?: Maybe<Membership>
   memberships: Array<Membership>
+  /** Get list of most followed channels */
+  mostFollowedChannels: Array<ChannelFollowsInfo>
+  /** Get list of most followed channels of all time */
+  mostFollowedChannelsAllTime?: Maybe<Array<ChannelFollowsInfo>>
+  /** Get list of channels with most views in given period */
+  mostViewedChannels?: Maybe<Array<EntityViewsInfo>>
+  /** Get list of channels with most views of all time */
+  mostViewedChannelsAllTime?: Maybe<Array<EntityViewsInfo>>
+  /** Get most viewed list of videos */
+  mostViewedVideos?: Maybe<Array<EntityViewsInfo>>
+  /** Get most viewed list of videos of all time */
+  mostViewedVideosAllTime?: Maybe<Array<EntityViewsInfo>>
   search: Array<SearchFtsOutput>
   videoByUniqueInput?: Maybe<Video>
   videoCategories: Array<VideoCategory>
@@ -314,6 +328,7 @@ export type QueryChannelViewsArgs = {
 export type QueryChannelsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
+  orderBy?: Maybe<Array<ChannelOrderByInput>>
   where?: Maybe<ChannelWhereInput>
 }
 
@@ -332,6 +347,33 @@ export type QueryMembershipsArgs = {
   where: MembershipWhereInput
 }
 
+export type QueryMostFollowedChannelsArgs = {
+  limit?: Maybe<Scalars['Int']>
+  timePeriodDays: Scalars['Int']
+}
+
+export type QueryMostFollowedChannelsAllTimeArgs = {
+  limit: Scalars['Int']
+}
+
+export type QueryMostViewedChannelsArgs = {
+  limit?: Maybe<Scalars['Int']>
+  timePeriodDays: Scalars['Int']
+}
+
+export type QueryMostViewedChannelsAllTimeArgs = {
+  limit: Scalars['Int']
+}
+
+export type QueryMostViewedVideosArgs = {
+  limit?: Maybe<Scalars['Int']>
+  timePeriodDays: Scalars['Int']
+}
+
+export type QueryMostViewedVideosAllTimeArgs = {
+  limit: Scalars['Int']
+}
+
 export type QuerySearchArgs = {
   limit?: Maybe<Scalars['Int']>
   text: Scalars['String']
@@ -399,6 +441,7 @@ export type Mutation = {
 }
 
 export type MutationAddVideoViewArgs = {
+  categoryId?: Maybe<Scalars['ID']>
   channelId: Scalars['ID']
   videoId: Scalars['ID']
 }

+ 305 - 56
src/api/queries/__generated__/channels.generated.tsx

@@ -24,7 +24,7 @@ export type AllChannelFieldsFragment = {
   isCensored: boolean
   coverPhotoUrls: Array<string>
   coverPhotoAvailability: Types.AssetAvailability
-  language?: Types.Maybe<{ __typename?: 'Language'; iso: string }>
+  language?: Types.Maybe<{ __typename?: 'Language'; id: string; iso: string }>
   ownerMember?: Types.Maybe<{ __typename?: 'Membership'; id: string; handle: string; avatarUri?: Types.Maybe<string> }>
   coverPhotoDataObject?: Types.Maybe<{ __typename?: 'DataObject' } & DataObjectFieldsFragment>
 } & BasicChannelFieldsFragment
@@ -57,9 +57,9 @@ export type GetVideoCountQuery = {
 }
 
 export type GetChannelsQueryVariables = Types.Exact<{
-  offset?: Types.Maybe<Types.Scalars['Int']>
-  limit?: Types.Maybe<Types.Scalars['Int']>
   where?: Types.Maybe<Types.ChannelWhereInput>
+  limit?: Types.Maybe<Types.Scalars['Int']>
+  orderBy?: Types.Maybe<Array<Types.ChannelOrderByInput> | Types.ChannelOrderByInput>
 }>
 
 export type GetChannelsQuery = {
@@ -71,6 +71,7 @@ export type GetChannelsConnectionQueryVariables = Types.Exact<{
   first?: Types.Maybe<Types.Scalars['Int']>
   after?: Types.Maybe<Types.Scalars['String']>
   where?: Types.Maybe<Types.ChannelWhereInput>
+  orderBy?: Types.Maybe<Array<Types.ChannelOrderByInput> | Types.ChannelOrderByInput>
 }>
 
 export type GetChannelsConnectionQuery = {
@@ -87,6 +88,15 @@ export type GetChannelsConnectionQuery = {
   }
 }
 
+export type GetChannelViewsQueryVariables = Types.Exact<{
+  channelId: Types.Scalars['ID']
+}>
+
+export type GetChannelViewsQuery = {
+  __typename?: 'Query'
+  channelViews?: Types.Maybe<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>
+}
+
 export type GetChannelFollowsQueryVariables = Types.Exact<{
   channelId: Types.Scalars['ID']
 }>
@@ -105,15 +115,6 @@ export type GetBatchedChannelFollowsQuery = {
   batchedChannelFollows: Array<Types.Maybe<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>>
 }
 
-export type GetChannelViewsQueryVariables = Types.Exact<{
-  channelId: Types.Scalars['ID']
-}>
-
-export type GetChannelViewsQuery = {
-  __typename?: 'Query'
-  channelViews?: Types.Maybe<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>
-}
-
 export type GetBatchedChannelViewsQueryVariables = Types.Exact<{
   channelIdList: Array<Types.Scalars['ID']> | Types.Scalars['ID']
 }>
@@ -141,6 +142,44 @@ export type UnfollowChannelMutation = {
   unfollowChannel: { __typename?: 'ChannelFollowsInfo'; id: string; follows: number }
 }
 
+export type GetMostViewedChannelsQueryVariables = Types.Exact<{
+  timePeriodDays: Types.Scalars['Int']
+  limit?: Types.Maybe<Types.Scalars['Int']>
+}>
+
+export type GetMostViewedChannelsQuery = {
+  __typename?: 'Query'
+  mostViewedChannels?: Types.Maybe<Array<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
+}
+
+export type GetMostViewedChannelsAllTimeQueryVariables = Types.Exact<{
+  limit: Types.Scalars['Int']
+}>
+
+export type GetMostViewedChannelsAllTimeQuery = {
+  __typename?: 'Query'
+  mostViewedChannelsAllTime?: Types.Maybe<Array<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
+}
+
+export type GetMostFollowedChannelsQueryVariables = Types.Exact<{
+  timePeriodDays: Types.Scalars['Int']
+  limit?: Types.Maybe<Types.Scalars['Int']>
+}>
+
+export type GetMostFollowedChannelsQuery = {
+  __typename?: 'Query'
+  mostFollowedChannels: Array<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>
+}
+
+export type GetMostFollowedChannelsAllTimeQueryVariables = Types.Exact<{
+  limit: Types.Scalars['Int']
+}>
+
+export type GetMostFollowedChannelsAllTimeQuery = {
+  __typename?: 'Query'
+  mostFollowedChannelsAllTime?: Types.Maybe<Array<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>>
+}
+
 export const BasicChannelFieldsFragmentDoc = gql`
   fragment BasicChannelFields on Channel {
     id
@@ -163,6 +202,7 @@ export const AllChannelFieldsFragmentDoc = gql`
     isPublic
     isCensored
     language {
+      id
       iso
     }
     ownerMember {
@@ -291,8 +331,8 @@ export type GetVideoCountQueryHookResult = ReturnType<typeof useGetVideoCountQue
 export type GetVideoCountLazyQueryHookResult = ReturnType<typeof useGetVideoCountLazyQuery>
 export type GetVideoCountQueryResult = Apollo.QueryResult<GetVideoCountQuery, GetVideoCountQueryVariables>
 export const GetChannelsDocument = gql`
-  query GetChannels($offset: Int, $limit: Int, $where: ChannelWhereInput) {
-    channels(offset: $offset, limit: $limit, where: $where) {
+  query GetChannels($where: ChannelWhereInput, $limit: Int = 50, $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]) {
+    channels(where: $where, orderBy: $orderBy, limit: $limit) {
       ...AllChannelFields
     }
   }
@@ -311,9 +351,9 @@ export const GetChannelsDocument = gql`
  * @example
  * const { data, loading, error } = useGetChannelsQuery({
  *   variables: {
- *      offset: // value for 'offset'
- *      limit: // value for 'limit'
  *      where: // value for 'where'
+ *      limit: // value for 'limit'
+ *      orderBy: // value for 'orderBy'
  *   },
  * });
  */
@@ -331,8 +371,13 @@ export type GetChannelsQueryHookResult = ReturnType<typeof useGetChannelsQuery>
 export type GetChannelsLazyQueryHookResult = ReturnType<typeof useGetChannelsLazyQuery>
 export type GetChannelsQueryResult = Apollo.QueryResult<GetChannelsQuery, GetChannelsQueryVariables>
 export const GetChannelsConnectionDocument = gql`
-  query GetChannelsConnection($first: Int, $after: String, $where: ChannelWhereInput) {
-    channelsConnection(first: $first, after: $after, where: $where, orderBy: [createdAt_DESC]) {
+  query GetChannelsConnection(
+    $first: Int
+    $after: String
+    $where: ChannelWhereInput
+    $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]
+  ) {
+    channelsConnection(first: $first, after: $after, where: $where, orderBy: $orderBy) {
       edges {
         cursor
         node {
@@ -364,6 +409,7 @@ export const GetChannelsConnectionDocument = gql`
  *      first: // value for 'first'
  *      after: // value for 'after'
  *      where: // value for 'where'
+ *      orderBy: // value for 'orderBy'
  *   },
  * });
  */
@@ -389,6 +435,44 @@ export type GetChannelsConnectionQueryResult = Apollo.QueryResult<
   GetChannelsConnectionQuery,
   GetChannelsConnectionQueryVariables
 >
+export const GetChannelViewsDocument = gql`
+  query GetChannelViews($channelId: ID!) {
+    channelViews(channelId: $channelId) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetChannelViewsQuery__
+ *
+ * To run a query within a React component, call `useGetChannelViewsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetChannelViewsQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetChannelViewsQuery({
+ *   variables: {
+ *      channelId: // value for 'channelId'
+ *   },
+ * });
+ */
+export function useGetChannelViewsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetChannelViewsQuery, GetChannelViewsQueryVariables>
+) {
+  return Apollo.useQuery<GetChannelViewsQuery, GetChannelViewsQueryVariables>(GetChannelViewsDocument, baseOptions)
+}
+export function useGetChannelViewsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetChannelViewsQuery, GetChannelViewsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetChannelViewsQuery, GetChannelViewsQueryVariables>(GetChannelViewsDocument, baseOptions)
+}
+export type GetChannelViewsQueryHookResult = ReturnType<typeof useGetChannelViewsQuery>
+export type GetChannelViewsLazyQueryHookResult = ReturnType<typeof useGetChannelViewsLazyQuery>
+export type GetChannelViewsQueryResult = Apollo.QueryResult<GetChannelViewsQuery, GetChannelViewsQueryVariables>
 export const GetChannelFollowsDocument = gql`
   query GetChannelFollows($channelId: ID!) {
     channelFollows(channelId: $channelId) {
@@ -480,44 +564,6 @@ export type GetBatchedChannelFollowsQueryResult = Apollo.QueryResult<
   GetBatchedChannelFollowsQuery,
   GetBatchedChannelFollowsQueryVariables
 >
-export const GetChannelViewsDocument = gql`
-  query GetChannelViews($channelId: ID!) {
-    channelViews(channelId: $channelId) {
-      id
-      views
-    }
-  }
-`
-
-/**
- * __useGetChannelViewsQuery__
- *
- * To run a query within a React component, call `useGetChannelViewsQuery` and pass it any options that fit your needs.
- * When your component renders, `useGetChannelViewsQuery` returns an object from Apollo Client that contains loading, error, and data properties
- * you can use to render your UI.
- *
- * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
- *
- * @example
- * const { data, loading, error } = useGetChannelViewsQuery({
- *   variables: {
- *      channelId: // value for 'channelId'
- *   },
- * });
- */
-export function useGetChannelViewsQuery(
-  baseOptions: Apollo.QueryHookOptions<GetChannelViewsQuery, GetChannelViewsQueryVariables>
-) {
-  return Apollo.useQuery<GetChannelViewsQuery, GetChannelViewsQueryVariables>(GetChannelViewsDocument, baseOptions)
-}
-export function useGetChannelViewsLazyQuery(
-  baseOptions?: Apollo.LazyQueryHookOptions<GetChannelViewsQuery, GetChannelViewsQueryVariables>
-) {
-  return Apollo.useLazyQuery<GetChannelViewsQuery, GetChannelViewsQueryVariables>(GetChannelViewsDocument, baseOptions)
-}
-export type GetChannelViewsQueryHookResult = ReturnType<typeof useGetChannelViewsQuery>
-export type GetChannelViewsLazyQueryHookResult = ReturnType<typeof useGetChannelViewsLazyQuery>
-export type GetChannelViewsQueryResult = Apollo.QueryResult<GetChannelViewsQuery, GetChannelViewsQueryVariables>
 export const GetBatchedChannelViewsDocument = gql`
   query GetBatchedChannelViews($channelIdList: [ID!]!) {
     batchedChannelsViews(channelIdList: $channelIdList) {
@@ -647,3 +693,206 @@ export type UnfollowChannelMutationOptions = Apollo.BaseMutationOptions<
   UnfollowChannelMutation,
   UnfollowChannelMutationVariables
 >
+export const GetMostViewedChannelsDocument = gql`
+  query GetMostViewedChannels($timePeriodDays: Int!, $limit: Int) {
+    mostViewedChannels(timePeriodDays: $timePeriodDays, limit: $limit) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetMostViewedChannelsQuery__
+ *
+ * To run a query within a React component, call `useGetMostViewedChannelsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostViewedChannelsQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostViewedChannelsQuery({
+ *   variables: {
+ *      timePeriodDays: // value for 'timePeriodDays'
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostViewedChannelsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostViewedChannelsQuery, GetMostViewedChannelsQueryVariables>
+) {
+  return Apollo.useQuery<GetMostViewedChannelsQuery, GetMostViewedChannelsQueryVariables>(
+    GetMostViewedChannelsDocument,
+    baseOptions
+  )
+}
+export function useGetMostViewedChannelsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetMostViewedChannelsQuery, GetMostViewedChannelsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetMostViewedChannelsQuery, GetMostViewedChannelsQueryVariables>(
+    GetMostViewedChannelsDocument,
+    baseOptions
+  )
+}
+export type GetMostViewedChannelsQueryHookResult = ReturnType<typeof useGetMostViewedChannelsQuery>
+export type GetMostViewedChannelsLazyQueryHookResult = ReturnType<typeof useGetMostViewedChannelsLazyQuery>
+export type GetMostViewedChannelsQueryResult = Apollo.QueryResult<
+  GetMostViewedChannelsQuery,
+  GetMostViewedChannelsQueryVariables
+>
+export const GetMostViewedChannelsAllTimeDocument = gql`
+  query GetMostViewedChannelsAllTime($limit: Int!) {
+    mostViewedChannelsAllTime(limit: $limit) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetMostViewedChannelsAllTimeQuery__
+ *
+ * To run a query within a React component, call `useGetMostViewedChannelsAllTimeQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostViewedChannelsAllTimeQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostViewedChannelsAllTimeQuery({
+ *   variables: {
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostViewedChannelsAllTimeQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostViewedChannelsAllTimeQuery, GetMostViewedChannelsAllTimeQueryVariables>
+) {
+  return Apollo.useQuery<GetMostViewedChannelsAllTimeQuery, GetMostViewedChannelsAllTimeQueryVariables>(
+    GetMostViewedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export function useGetMostViewedChannelsAllTimeLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<
+    GetMostViewedChannelsAllTimeQuery,
+    GetMostViewedChannelsAllTimeQueryVariables
+  >
+) {
+  return Apollo.useLazyQuery<GetMostViewedChannelsAllTimeQuery, GetMostViewedChannelsAllTimeQueryVariables>(
+    GetMostViewedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export type GetMostViewedChannelsAllTimeQueryHookResult = ReturnType<typeof useGetMostViewedChannelsAllTimeQuery>
+export type GetMostViewedChannelsAllTimeLazyQueryHookResult = ReturnType<
+  typeof useGetMostViewedChannelsAllTimeLazyQuery
+>
+export type GetMostViewedChannelsAllTimeQueryResult = Apollo.QueryResult<
+  GetMostViewedChannelsAllTimeQuery,
+  GetMostViewedChannelsAllTimeQueryVariables
+>
+export const GetMostFollowedChannelsDocument = gql`
+  query GetMostFollowedChannels($timePeriodDays: Int!, $limit: Int) {
+    mostFollowedChannels(timePeriodDays: $timePeriodDays, limit: $limit) {
+      id
+      follows
+    }
+  }
+`
+
+/**
+ * __useGetMostFollowedChannelsQuery__
+ *
+ * To run a query within a React component, call `useGetMostFollowedChannelsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostFollowedChannelsQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostFollowedChannelsQuery({
+ *   variables: {
+ *      timePeriodDays: // value for 'timePeriodDays'
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostFollowedChannelsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>
+) {
+  return Apollo.useQuery<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>(
+    GetMostFollowedChannelsDocument,
+    baseOptions
+  )
+}
+export function useGetMostFollowedChannelsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>(
+    GetMostFollowedChannelsDocument,
+    baseOptions
+  )
+}
+export type GetMostFollowedChannelsQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsQuery>
+export type GetMostFollowedChannelsLazyQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsLazyQuery>
+export type GetMostFollowedChannelsQueryResult = Apollo.QueryResult<
+  GetMostFollowedChannelsQuery,
+  GetMostFollowedChannelsQueryVariables
+>
+export const GetMostFollowedChannelsAllTimeDocument = gql`
+  query GetMostFollowedChannelsAllTime($limit: Int!) {
+    mostFollowedChannelsAllTime(limit: $limit) {
+      id
+      follows
+    }
+  }
+`
+
+/**
+ * __useGetMostFollowedChannelsAllTimeQuery__
+ *
+ * To run a query within a React component, call `useGetMostFollowedChannelsAllTimeQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostFollowedChannelsAllTimeQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostFollowedChannelsAllTimeQuery({
+ *   variables: {
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostFollowedChannelsAllTimeQuery(
+  baseOptions: Apollo.QueryHookOptions<
+    GetMostFollowedChannelsAllTimeQuery,
+    GetMostFollowedChannelsAllTimeQueryVariables
+  >
+) {
+  return Apollo.useQuery<GetMostFollowedChannelsAllTimeQuery, GetMostFollowedChannelsAllTimeQueryVariables>(
+    GetMostFollowedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export function useGetMostFollowedChannelsAllTimeLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<
+    GetMostFollowedChannelsAllTimeQuery,
+    GetMostFollowedChannelsAllTimeQueryVariables
+  >
+) {
+  return Apollo.useLazyQuery<GetMostFollowedChannelsAllTimeQuery, GetMostFollowedChannelsAllTimeQueryVariables>(
+    GetMostFollowedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export type GetMostFollowedChannelsAllTimeQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsAllTimeQuery>
+export type GetMostFollowedChannelsAllTimeLazyQueryHookResult = ReturnType<
+  typeof useGetMostFollowedChannelsAllTimeLazyQuery
+>
+export type GetMostFollowedChannelsAllTimeQueryResult = Apollo.QueryResult<
+  GetMostFollowedChannelsAllTimeQuery,
+  GetMostFollowedChannelsAllTimeQueryVariables
+>

+ 118 - 2
src/api/queries/__generated__/videos.generated.tsx

@@ -123,9 +123,29 @@ export type GetBatchedVideoViewsQuery = {
   batchedVideoViews: Array<Types.Maybe<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
 }
 
+export type GetMostViewedVideosQueryVariables = Types.Exact<{
+  timePeriodDays: Types.Scalars['Int']
+  limit?: Types.Maybe<Types.Scalars['Int']>
+}>
+
+export type GetMostViewedVideosQuery = {
+  __typename?: 'Query'
+  mostViewedVideos?: Types.Maybe<Array<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
+}
+
+export type GetMostViewedVideosAllTimeQueryVariables = Types.Exact<{
+  limit: Types.Scalars['Int']
+}>
+
+export type GetMostViewedVideosAllTimeQuery = {
+  __typename?: 'Query'
+  mostViewedVideosAllTime?: Types.Maybe<Array<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
+}
+
 export type AddVideoViewMutationVariables = Types.Exact<{
   videoId: Types.Scalars['ID']
   channelId: Types.Scalars['ID']
+  categoryId?: Types.Maybe<Types.Scalars['ID']>
 }>
 
 export type AddVideoViewMutation = {
@@ -466,9 +486,104 @@ export type GetBatchedVideoViewsQueryResult = Apollo.QueryResult<
   GetBatchedVideoViewsQuery,
   GetBatchedVideoViewsQueryVariables
 >
+export const GetMostViewedVideosDocument = gql`
+  query GetMostViewedVideos($timePeriodDays: Int!, $limit: Int) {
+    mostViewedVideos(timePeriodDays: $timePeriodDays, limit: $limit) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetMostViewedVideosQuery__
+ *
+ * To run a query within a React component, call `useGetMostViewedVideosQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostViewedVideosQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostViewedVideosQuery({
+ *   variables: {
+ *      timePeriodDays: // value for 'timePeriodDays'
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostViewedVideosQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostViewedVideosQuery, GetMostViewedVideosQueryVariables>
+) {
+  return Apollo.useQuery<GetMostViewedVideosQuery, GetMostViewedVideosQueryVariables>(
+    GetMostViewedVideosDocument,
+    baseOptions
+  )
+}
+export function useGetMostViewedVideosLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetMostViewedVideosQuery, GetMostViewedVideosQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetMostViewedVideosQuery, GetMostViewedVideosQueryVariables>(
+    GetMostViewedVideosDocument,
+    baseOptions
+  )
+}
+export type GetMostViewedVideosQueryHookResult = ReturnType<typeof useGetMostViewedVideosQuery>
+export type GetMostViewedVideosLazyQueryHookResult = ReturnType<typeof useGetMostViewedVideosLazyQuery>
+export type GetMostViewedVideosQueryResult = Apollo.QueryResult<
+  GetMostViewedVideosQuery,
+  GetMostViewedVideosQueryVariables
+>
+export const GetMostViewedVideosAllTimeDocument = gql`
+  query GetMostViewedVideosAllTime($limit: Int!) {
+    mostViewedVideosAllTime(limit: $limit) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetMostViewedVideosAllTimeQuery__
+ *
+ * To run a query within a React component, call `useGetMostViewedVideosAllTimeQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostViewedVideosAllTimeQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostViewedVideosAllTimeQuery({
+ *   variables: {
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostViewedVideosAllTimeQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostViewedVideosAllTimeQuery, GetMostViewedVideosAllTimeQueryVariables>
+) {
+  return Apollo.useQuery<GetMostViewedVideosAllTimeQuery, GetMostViewedVideosAllTimeQueryVariables>(
+    GetMostViewedVideosAllTimeDocument,
+    baseOptions
+  )
+}
+export function useGetMostViewedVideosAllTimeLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetMostViewedVideosAllTimeQuery, GetMostViewedVideosAllTimeQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetMostViewedVideosAllTimeQuery, GetMostViewedVideosAllTimeQueryVariables>(
+    GetMostViewedVideosAllTimeDocument,
+    baseOptions
+  )
+}
+export type GetMostViewedVideosAllTimeQueryHookResult = ReturnType<typeof useGetMostViewedVideosAllTimeQuery>
+export type GetMostViewedVideosAllTimeLazyQueryHookResult = ReturnType<typeof useGetMostViewedVideosAllTimeLazyQuery>
+export type GetMostViewedVideosAllTimeQueryResult = Apollo.QueryResult<
+  GetMostViewedVideosAllTimeQuery,
+  GetMostViewedVideosAllTimeQueryVariables
+>
 export const AddVideoViewDocument = gql`
-  mutation AddVideoView($videoId: ID!, $channelId: ID!) {
-    addVideoView(videoId: $videoId, channelId: $channelId) {
+  mutation AddVideoView($videoId: ID!, $channelId: ID!, $categoryId: ID) {
+    addVideoView(videoId: $videoId, channelId: $channelId, categoryId: $categoryId) {
       id
       views
     }
@@ -491,6 +606,7 @@ export type AddVideoViewMutationFn = Apollo.MutationFunction<AddVideoViewMutatio
  *   variables: {
  *      videoId: // value for 'videoId'
  *      channelId: // value for 'channelId'
+ *      categoryId: // value for 'categoryId'
  *   },
  * });
  */

+ 46 - 11
src/api/queries/channels.graphql

@@ -18,6 +18,7 @@ fragment AllChannelFields on Channel {
   isPublic
   isCensored
   language {
+    id
     iso
   }
   ownerMember {
@@ -50,14 +51,19 @@ query GetVideoCount($where: VideoWhereInput) {
   }
 }
 
-query GetChannels($offset: Int, $limit: Int, $where: ChannelWhereInput) {
-  channels(offset: $offset, limit: $limit, where: $where) {
+query GetChannels($where: ChannelWhereInput, $limit: Int = 50, $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]) {
+  channels(where: $where, orderBy: $orderBy, limit: $limit) {
     ...AllChannelFields
   }
 }
 
-query GetChannelsConnection($first: Int, $after: String, $where: ChannelWhereInput) {
-  channelsConnection(first: $first, after: $after, where: $where, orderBy: [createdAt_DESC]) {
+query GetChannelsConnection(
+  $first: Int
+  $after: String
+  $where: ChannelWhereInput
+  $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]
+) {
+  channelsConnection(first: $first, after: $after, where: $where, orderBy: $orderBy) {
     edges {
       cursor
       node {
@@ -74,6 +80,14 @@ query GetChannelsConnection($first: Int, $after: String, $where: ChannelWhereInp
 
 ### Orion
 
+# modyfying this query name will need a sync-up in `src/api/client/resolvers.ts`
+query GetChannelViews($channelId: ID!) {
+  channelViews(channelId: $channelId) {
+    id
+    views
+  }
+}
+
 # modyfying this query name will need a sync-up in `src/api/client/resolvers.ts`
 query GetChannelFollows($channelId: ID!) {
   channelFollows(channelId: $channelId) {
@@ -88,13 +102,6 @@ query GetBatchedChannelFollows($channelIdList: [ID!]!) {
     follows
   }
 }
-# modyfying this query name will need a sync-up in `src/api/client/resolvers.ts`
-query GetChannelViews($channelId: ID!) {
-  channelViews(channelId: $channelId) {
-    id
-    views
-  }
-}
 
 query GetBatchedChannelViews($channelIdList: [ID!]!) {
   batchedChannelsViews(channelIdList: $channelIdList) {
@@ -116,3 +123,31 @@ mutation UnfollowChannel($channelId: ID!) {
     follows
   }
 }
+
+query GetMostViewedChannels($timePeriodDays: Int!, $limit: Int) {
+  mostViewedChannels(timePeriodDays: $timePeriodDays, limit: $limit) {
+    id
+    views
+  }
+}
+
+query GetMostViewedChannelsAllTime($limit: Int!) {
+  mostViewedChannelsAllTime(limit: $limit) {
+    id
+    views
+  }
+}
+
+query GetMostFollowedChannels($timePeriodDays: Int!, $limit: Int) {
+  mostFollowedChannels(timePeriodDays: $timePeriodDays, limit: $limit) {
+    id
+    follows
+  }
+}
+
+query GetMostFollowedChannelsAllTime($limit: Int!) {
+  mostFollowedChannelsAllTime(limit: $limit) {
+    id
+    follows
+  }
+}

+ 16 - 2
src/api/queries/videos.graphql

@@ -118,8 +118,22 @@ query GetBatchedVideoViews($videoIdList: [ID!]!) {
   }
 }
 
-mutation AddVideoView($videoId: ID!, $channelId: ID!) {
-  addVideoView(videoId: $videoId, channelId: $channelId) {
+query GetMostViewedVideos($timePeriodDays: Int!, $limit: Int) {
+  mostViewedVideos(timePeriodDays: $timePeriodDays, limit: $limit) {
+    id
+    views
+  }
+}
+
+query GetMostViewedVideosAllTime($limit: Int!) {
+  mostViewedVideosAllTime(limit: $limit) {
+    id
+    views
+  }
+}
+
+mutation AddVideoView($videoId: ID!, $channelId: ID!, $categoryId: ID) {
+  addVideoView(videoId: $videoId, channelId: $channelId, categoryId: $categoryId) {
     id
     views
   }

+ 7 - 5
src/api/schemas/extendedQueryNode.graphql

@@ -1,9 +1,5 @@
 scalar DateTime
 
-type Language {
-  iso: String!
-}
-
 type VideoCategory {
   id: ID!
   name: String
@@ -121,6 +117,7 @@ input ChannelWhereInput {
   isCensored_eq: Boolean
   coverPhotoAvailability_eq: AssetAvailability
   avatarPhotoAvailability_eq: AssetAvailability
+  languageId_eq: ID
 }
 
 input ChannelWhereUniqueInput {
@@ -256,6 +253,11 @@ type ProcessorState {
   chainHead: Float!
 }
 
+type Language {
+  id: ID!
+  iso: String!
+}
+
 type Query {
   # Lookup a membership by its ID
   membershipByUniqueInput(where: MembershipWhereUniqueInput!): Membership
@@ -267,7 +269,7 @@ type Query {
   channelByUniqueInput(where: ChannelWhereUniqueInput!): Channel
 
   # List all channels by given constraints
-  channels(offset: Int, limit: Int, where: ChannelWhereInput): [Channel!]!
+  channels(offset: Int, limit: Int, orderBy: [ChannelOrderByInput!], where: ChannelWhereInput): [Channel!]!
 
   # List all channel by given constraints
   channelsConnection(

+ 31 - 1
src/api/schemas/orion.graphql

@@ -17,7 +17,7 @@ type Mutation {
   """
   Add a single view to the target video's count
   """
-  addVideoView(channelId: ID!, videoId: ID!): EntityViewsInfo!
+  addVideoView(categoryId: ID, channelId: ID!, videoId: ID!): EntityViewsInfo!
 
   """
   Add a single follow to the target channel
@@ -56,6 +56,36 @@ type Query {
   """
   channelViews(channelId: ID!): EntityViewsInfo
 
+  """
+  Get list of most followed channels
+  """
+  mostFollowedChannels(limit: Int, timePeriodDays: Int!): [ChannelFollowsInfo!]!
+
+  """
+  Get list of most followed channels of all time
+  """
+  mostFollowedChannelsAllTime(limit: Int!): [ChannelFollowsInfo!]
+
+  """
+  Get list of channels with most views in given period
+  """
+  mostViewedChannels(limit: Int, timePeriodDays: Int!): [EntityViewsInfo!]
+
+  """
+  Get list of channels with most views of all time
+  """
+  mostViewedChannelsAllTime(limit: Int!): [EntityViewsInfo!]
+
+  """
+  Get most viewed list of videos
+  """
+  mostViewedVideos(limit: Int, timePeriodDays: Int!): [EntityViewsInfo!]
+
+  """
+  Get most viewed list of videos of all time
+  """
+  mostViewedVideosAllTime(limit: Int!): [EntityViewsInfo!]
+
   """
   Get views count for a single video
   """

+ 18 - 16
src/components/ChannelCard.tsx

@@ -1,35 +1,37 @@
 import React from 'react'
 
 import { useChannel } from '@/api/hooks'
-import { useChannelVideoCount } from '@/api/hooks/channel'
-import { absoluteRoutes } from '@/config/routes'
+import { useHandleFollowChannel } from '@/hooks'
 import { AssetType, useAsset } from '@/providers'
 import { ChannelCardBase } from '@/shared/components'
 
-type ChannelCardProps = {
+export type ChannelCardProps = {
   id?: string
   className?: string
-  onClick?: (e: React.MouseEvent<HTMLElement>) => void
+  onClick?: () => void
 }
 
-export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick }) => {
-  const { channel, loading } = useChannel(id ?? '', { fetchPolicy: 'cache-first', skip: !id })
+export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className }) => {
+  const { channel, loading } = useChannel(id ?? '', { skip: !id })
   const { url } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
-  const { videoCount } = useChannelVideoCount(id ?? '', undefined, {
-    fetchPolicy: 'cache-first',
-    skip: !id,
-  })
-  const isLoading = loading || id === undefined
+
+  const { toggleFollowing, isFollowing } = useHandleFollowChannel(id)
+
+  const handleFollow = (e: React.MouseEvent) => {
+    e.preventDefault()
+    toggleFollowing()
+  }
 
   return (
     <ChannelCardBase
       className={className}
+      isLoading={loading || !channel}
+      id={channel?.id}
+      avatarUrl={url}
+      follows={channel?.follows}
+      onFollow={handleFollow}
+      isFollowing={isFollowing}
       title={channel?.title}
-      channelHref={id ? absoluteRoutes.viewer.channel(id) : undefined}
-      videoCount={videoCount}
-      loading={isLoading}
-      onClick={onClick}
-      assetUrl={url}
     />
   )
 }

+ 48 - 23
src/components/ChannelGallery.tsx

@@ -1,40 +1,65 @@
-import styled from '@emotion/styled'
-import React from 'react'
+import React, { useMemo } from 'react'
 
 import { BasicChannelFieldsFragment } from '@/api/queries'
-import { Gallery } from '@/shared/components'
-import { sizes } from '@/shared/theme'
-
-import { ChannelCard } from './ChannelCard'
+import { ChannelCard } from '@/components/ChannelCard'
+import { Gallery, RankingNumberTile, breakpointsOfGrid } from '@/shared/components'
 
 type ChannelGalleryProps = {
   title?: string
-  channels?: BasicChannelFieldsFragment[]
+  channels?: BasicChannelFieldsFragment[] | null
   loading?: boolean
   onChannelClick?: (id: string) => void
+  hasRanking?: boolean
 }
 
-const PLACEHOLDERS_COUNT = 12
+const PLACEHOLDERS_COUNT = 10
+const CAROUSEL_SMALL_BREAKPOINT = 688
+
+export const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels = [], loading, hasRanking }) => {
+  const breakpoints = useMemo(() => {
+    return breakpointsOfGrid({
+      breakpoints: 6,
+      minItemWidth: 300,
+      gridColumnGap: 24,
+      viewportContainerDifference: 64,
+    }).map((breakpoint, idx) => {
+      if (breakpoint <= CAROUSEL_SMALL_BREAKPOINT && hasRanking) {
+        return {
+          breakpoint,
+          settings: {
+            slidesToShow: idx + 1.5,
+            slidesToScroll: idx + 1,
+          },
+        }
+      }
+      return {
+        breakpoint,
+        settings: {
+          slidesToShow: idx + 1,
+          slidesToScroll: idx + 1,
+        },
+      }
+    })
+  }, [hasRanking])
 
-export const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels = [], loading, onChannelClick }) => {
-  if (!loading && channels?.length === 0) {
+  if (loading === false && channels?.length === 0) {
     return null
   }
 
-  const createClickHandler = (id?: string) => () => id && onChannelClick && onChannelClick(id)
-
-  const placeholderItems = Array.from({ length: loading ? PLACEHOLDERS_COUNT : 0 }, () => ({ id: undefined }))
+  const placeholderItems = Array.from({ length: loading || !channels?.length ? PLACEHOLDERS_COUNT : 0 }, () => ({
+    id: undefined,
+  }))
   return (
-    <Gallery title={title} itemWidth={220} exactWidth={true} paddingLeft={sizes(2, true)} paddingTop={sizes(2, true)}>
-      {[...channels, ...placeholderItems].map((channel, idx) => (
-        <StyledChannelCard key={idx} id={channel.id} onClick={createClickHandler(channel.id)} />
-      ))}
+    <Gallery title={title} responsive={breakpoints} itemWidth={350} dotsVisible>
+      {[...(channels ? channels : []), ...placeholderItems].map((channel, idx) =>
+        hasRanking ? (
+          <RankingNumberTile variant="channel" rankingNumber={idx + 1} key={idx}>
+            <ChannelCard id={channel.id} />
+          </RankingNumberTile>
+        ) : (
+          <ChannelCard key={idx} id={channel.id} />
+        )
+      )}
     </Gallery>
   )
 }
-
-const StyledChannelCard = styled(ChannelCard)`
-  + * {
-    margin-left: 16px;
-  }
-`

+ 3 - 9
src/components/ChannelLink/ChannelLink.style.ts

@@ -1,8 +1,8 @@
 import styled from '@emotion/styled'
 import { Link } from 'react-router-dom'
 
-import { SkeletonLoader } from '@/shared/components'
-import { colors, sizes, typography } from '@/shared/theme'
+import { SkeletonLoader, Text } from '@/shared/components'
+import { sizes } from '@/shared/theme'
 
 type ContainerProps = {
   disabled?: boolean
@@ -19,13 +19,7 @@ type HandleProps = {
   withAvatar: boolean
 }
 
-export const Handle = styled.span<HandleProps>`
-  display: inline-block;
-  font-family: ${typography.fonts.headers};
-  font-size: 1rem;
-  line-height: 1;
-  font-weight: bold;
-  color: ${colors.white};
+export const StyledText = styled(Text)<HandleProps>`
   margin-left: ${({ withAvatar }) => (withAvatar ? sizes(2) : 0)};
 `
 

+ 14 - 4
src/components/ChannelLink/ChannelLink.tsx

@@ -5,15 +5,16 @@ import { BasicChannelFieldsFragment } from '@/api/queries'
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, useAsset } from '@/providers'
 import { Avatar, AvatarSize } from '@/shared/components/Avatar'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
-import { Container, Handle, HandleSkeletonLoader } from './ChannelLink.style'
+import { Container, HandleSkeletonLoader, StyledText } from './ChannelLink.style'
 
 type ChannelLinkProps = {
   id?: string
   onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
   hideHandle?: boolean
   hideAvatar?: boolean
+  variant?: 'primary' | 'secondary'
   noLink?: boolean
   overrideChannel?: BasicChannelFieldsFragment
   avatarSize?: AvatarSize
@@ -25,6 +26,7 @@ export const ChannelLink: React.FC<ChannelLinkProps> = ({
   id,
   hideHandle,
   hideAvatar,
+  variant = 'primary',
   noLink,
   overrideChannel,
   avatarSize = 'default',
@@ -34,7 +36,7 @@ export const ChannelLink: React.FC<ChannelLinkProps> = ({
   const { channel } = useBasicChannel(id || '', {
     skip: !id,
     onCompleted: (data) => !data && onNotFound?.(),
-    onError: (error) => Logger.error('Failed to fetch channel', error),
+    onError: (error) => SentryLogger.error('Failed to fetch channel', 'ChannelLink', error, { channel: { id } }),
   })
   const { url: avatarPhotoUrl } = useAsset({
     entity: channel,
@@ -48,7 +50,15 @@ export const ChannelLink: React.FC<ChannelLinkProps> = ({
       {!hideAvatar && <Avatar loading={!displayedChannel} size={avatarSize} assetUrl={avatarPhotoUrl} />}
       {!hideHandle &&
         (displayedChannel ? (
-          <Handle withAvatar={!hideAvatar}>{displayedChannel.title}</Handle>
+          variant === 'secondary' ? (
+            <StyledText withAvatar={!hideAvatar} secondary variant="button2">
+              {displayedChannel.title}
+            </StyledText>
+          ) : (
+            <StyledText withAvatar={!hideAvatar} variant="h6">
+              {displayedChannel.title}
+            </StyledText>
+          )
         ) : (
           <HandleSkeletonLoader withAvatar={!hideAvatar} height={16} width={150} />
         ))}

+ 43 - 0
src/components/ChannelWithVideos/ChannelWithVideos.style.ts

@@ -0,0 +1,43 @@
+import styled from '@emotion/styled'
+import { Link } from 'react-router-dom'
+
+import { Avatar, Button, Text } from '@/shared/components'
+import { sizes } from '@/shared/theme'
+
+export const ChannelCardAnchor = styled(Link)`
+  text-decoration: none;
+  align-items: center;
+  transition: transform, box-shadow;
+  display: inline-flex;
+  justify-content: unset;
+  margin-bottom: ${sizes(10)};
+`
+
+export const StyledAvatar = styled(Avatar)`
+  margin-right: ${sizes(6)};
+`
+
+export const InfoWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: start;
+`
+
+export const ChannelTitle = styled(Text)`
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const ChannelFollows = styled(Text)`
+  margin-top: ${sizes(1)};
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const FollowButton = styled(Button)`
+  margin-top: ${sizes(2)};
+`

+ 108 - 0
src/components/ChannelWithVideos/ChannelWithVideos.tsx

@@ -0,0 +1,108 @@
+import React, { FC, useState } from 'react'
+
+import { useChannel } from '@/api/hooks'
+import { GetVideosConnectionDocument, GetVideosConnectionQuery, GetVideosConnectionQueryVariables } from '@/api/queries'
+import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
+import { VideoTile } from '@/components/VideoTile'
+import { absoluteRoutes } from '@/config/routes'
+import { useHandleFollowChannel } from '@/hooks'
+import { AssetType, useAsset } from '@/providers'
+import { Grid, SkeletonLoader } from '@/shared/components'
+import { SentryLogger } from '@/utils/logs'
+import { formatNumberShort } from '@/utils/number'
+
+import {
+  ChannelCardAnchor,
+  ChannelFollows,
+  ChannelTitle,
+  FollowButton,
+  InfoWrapper,
+  StyledAvatar,
+} from './ChannelWithVideos.style'
+
+type ChannelWithVideosProps = {
+  channelId?: string
+}
+
+const INITIAL_VIDEOS_PER_ROW = 4
+const INITAL_ROWS = 1
+
+export const ChannelWithVideos: FC<ChannelWithVideosProps> = ({ channelId }) => {
+  const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
+  const { channel, loading } = useChannel(channelId || '')
+
+  const { url: avatarUrl } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
+  const { toggleFollowing, isFollowing } = useHandleFollowChannel(channelId)
+  const { displayedItems, placeholdersCount, error } = useInfiniteGrid<
+    GetVideosConnectionQuery,
+    GetVideosConnectionQuery['videosConnection'],
+    GetVideosConnectionQueryVariables
+  >({
+    query: GetVideosConnectionDocument,
+    isReady: !!channelId,
+    skipCount: 0,
+    queryVariables: {
+      where: {
+        channelId_eq: channelId,
+        isPublic_eq: true,
+        isCensored_eq: false,
+      },
+    },
+    targetRowsCount: INITAL_ROWS,
+    dataAccessor: (rawData) => rawData?.videosConnection,
+    itemsPerRow: videosPerRow,
+    onError: (error) => SentryLogger.error('Failed to fetch videos', 'ChannelWithVideos', error),
+  })
+
+  const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))
+  const gridContent = (
+    <>
+      {[...displayedItems, ...placeholderItems]?.map((video, idx) => (
+        <VideoTile id={video.id} key={`channels-with-videos-${idx}`} showChannel />
+      ))}
+    </>
+  )
+
+  const isLoading = !channelId || loading
+
+  if (error) {
+    return null
+  }
+
+  return (
+    <>
+      <ChannelCardAnchor to={absoluteRoutes.viewer.channel(channelId)}>
+        <StyledAvatar size="channel" loading={isLoading} assetUrl={avatarUrl} />
+        <InfoWrapper>
+          {isLoading ? (
+            <SkeletonLoader width="120px" height="20px" bottomSpace="4px" />
+          ) : (
+            <ChannelTitle variant="h6">{channel?.title}</ChannelTitle>
+          )}
+          {isLoading ? (
+            <SkeletonLoader width="80px" height="20px" bottomSpace="8px" />
+          ) : (
+            <ChannelFollows variant="body2" secondary>
+              {formatNumberShort(channel?.follows || 0)} followers
+            </ChannelFollows>
+          )}
+          {isLoading ? (
+            <SkeletonLoader width="90px" height="40px" />
+          ) : (
+            <FollowButton
+              variant="secondary"
+              size={'medium'}
+              onClick={(e) => {
+                e.preventDefault()
+                toggleFollowing()
+              }}
+            >
+              {isFollowing ? 'Unfollow' : 'Follow'}
+            </FollowButton>
+          )}
+        </InfoWrapper>
+      </ChannelCardAnchor>
+      <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
+    </>
+  )
+}

+ 1 - 0
src/components/ChannelWithVideos/index.ts

@@ -0,0 +1 @@
+export * from './ChannelWithVideos'

+ 6 - 4
src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx

@@ -4,7 +4,7 @@ import { IconButton } from '@/shared/components'
 import { SvgGlyphPan, SvgGlyphZoomIn, SvgGlyphZoomOut } from '@/shared/icons'
 import { AssetDimensions, ImageCropData } from '@/types/cropper'
 import { validateImage } from '@/utils/image'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import {
   AlignInfo,
@@ -86,18 +86,20 @@ const ImageCropDialogComponent: React.ForwardRefRenderFunction<
   const handleFileChange = async () => {
     const files = inputRef.current?.files
     if (!files?.length) {
-      Logger.error('no files selected')
+      SentryLogger.error('No files selected for image cropping', 'ImageCropDialog')
       return
     }
+    const selectedFile = files[0]
     try {
-      const selectedFile = files[0]
       await validateImage(selectedFile)
       const fileUrl = URL.createObjectURL(selectedFile)
       setEditedImageHref(fileUrl)
       setShowDialog(true)
     } catch (error) {
       onError?.(error)
-      Logger.error(error)
+      SentryLogger.error('Failed to load image for image cropping', 'ImageCropDialog', error, {
+        file: { name: selectedFile.name, type: selectedFile.type, size: selectedFile.size },
+      })
     }
   }
 

+ 2 - 2
src/components/Dialogs/ImageCropDialog/cropper.ts

@@ -3,7 +3,7 @@ import 'cropperjs/dist/cropper.min.css'
 import { useEffect, useState } from 'react'
 
 import { AssetDimensions, ImageCropData } from '@/types/cropper'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 const MAX_ZOOM = 3
 
@@ -165,7 +165,7 @@ export const useCropper = ({ imageEl, imageType, cropData }: UseCropperOpts) =>
       }
       canvas.toBlob((blob) => {
         if (!blob) {
-          Logger.error('Empty blob from cropped canvas', { blob })
+          SentryLogger.error('Got an empty blob from cropped canvas', 'ImageCropDialog')
           return
         }
         const url = URL.createObjectURL(blob)

+ 43 - 0
src/components/DiscoverChannels.tsx

@@ -0,0 +1,43 @@
+import styled from '@emotion/styled'
+import React from 'react'
+
+import { ChannelEdge, VideoEdge } from '@/api/queries'
+import { InfiniteChannelWithVideosGrid } from '@/components/InfiniteGrids'
+import { sizes } from '@/shared/theme'
+
+type DiscoverChannelsProps = {
+  additionalLink?: {
+    name: string
+    url: string
+  }
+}
+
+export const DiscoverChannels: React.FC<DiscoverChannelsProps> = ({ additionalLink }) => {
+  const sortChannelsByFollowsDesc = (edges?: ChannelEdge[] | VideoEdge[]) => {
+    if (!edges) {
+      return []
+    }
+    return [...edges].sort((a, b) => {
+      if ('follows' in b.node && 'follows' in a.node) {
+        return (b.node.follows || 0) - (a.node.follows || 0)
+      } else {
+        return 0
+      }
+    })
+  }
+
+  return (
+    <StyledInfiniteChannelWithVideosGrid
+      title="Discover new channels"
+      onDemand
+      additionalSortFn={sortChannelsByFollowsDesc}
+      additionalLink={additionalLink}
+    />
+  )
+}
+
+const StyledInfiniteChannelWithVideosGrid = styled(InfiniteChannelWithVideosGrid)`
+  :not(:last-of-type) {
+    margin-bottom: ${sizes(38)};
+  }
+`

+ 0 - 31
src/components/ErrorFallback.tsx

@@ -1,31 +0,0 @@
-import styled from '@emotion/styled'
-import { FallbackRender } from '@sentry/react/dist/errorboundary'
-import React from 'react'
-
-import { Button, Text } from '@/shared/components'
-import { colors, sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
-
-const Container = styled.div`
-  padding: ${sizes(4)};
-  color: ${colors.gray[400]};
-  display: grid;
-  place-items: center;
-`
-
-const StyledButton = styled(Button)`
-  color: ${colors.white};
-`
-type FallbackProps = Partial<Parameters<FallbackRender>[0]>
-
-export const ErrorFallback: React.FC<FallbackProps> = ({ error, componentStack, resetError }) => {
-  Logger.error(`An error occurred in ${componentStack}`, error)
-  return (
-    <Container>
-      <Text>Something went wrong...</Text>
-      <StyledButton variant="tertiary" onClick={resetError}>
-        Try again
-      </StyledButton>
-    </Container>
-  )
-}

+ 6 - 4
src/components/InfiniteGrids/InfiniteChannelGrid.tsx

@@ -9,6 +9,7 @@ import {
 } from '@/api/queries'
 import { Grid, Text } from '@/shared/components'
 import { sizes } from '@/shared/theme'
+import { SentryLogger } from '@/utils/logs'
 
 import { useInfiniteGrid } from './useInfiniteGrid'
 
@@ -56,12 +57,9 @@ export const InfiniteChannelGrid: React.FC<InfiniteChannelGridProps> = ({
     targetRowsCount,
     dataAccessor: (rawData) => rawData?.channelsConnection,
     itemsPerRow: channelsPerRow,
+    onError: (error) => SentryLogger.error('Failed to fetch channels', 'InfiniteChannelsGrid', error),
   })
 
-  if (error) {
-    throw error
-  }
-
   const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))
   const gridContent = (
     <>
@@ -72,6 +70,10 @@ export const InfiniteChannelGrid: React.FC<InfiniteChannelGridProps> = ({
     </>
   )
 
+  if (error) {
+    return null
+  }
+
   if (displayedItems.length <= 0 && placeholdersCount <= 0) {
     return null
   }

+ 166 - 0
src/components/InfiniteGrids/InfiniteChannelWithVideosGrid.tsx

@@ -0,0 +1,166 @@
+import React, { FC, Fragment, useCallback, useState } from 'react'
+
+import {
+  ChannelEdge,
+  ChannelOrderByInput,
+  GetChannelsConnectionDocument,
+  GetChannelsConnectionQuery,
+  GetChannelsConnectionQueryVariables,
+  VideoEdge,
+} from '@/api/queries'
+import { ChannelWithVideos } from '@/components/ChannelWithVideos'
+import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
+import { languages } from '@/config/languages'
+import {
+  EmptyFallback,
+  GridHeadingContainer,
+  LoadMoreButton,
+  Select,
+  SkeletonLoader,
+  Text,
+  TitleContainer,
+} from '@/shared/components'
+import { SvgGlyphChevronRight } from '@/shared/icons'
+import { SentryLogger } from '@/utils/logs'
+
+import { AdditionalLink, LanguageSelectWrapper, LoadMoreButtonWrapper, Separator } from './InfiniteGrid.style'
+
+type InfiniteChannelWithVideosGridProps = {
+  onDemand?: boolean
+  title?: string
+  skipCount?: number
+  first?: number
+  orderBy?: ChannelOrderByInput
+  isReady?: boolean
+  className?: string
+  languageSelector?: boolean
+  idIn?: string[] | null
+  additionalLink?: {
+    name: string
+    url: string
+  }
+  maximumCount?: number
+  additionalSortFn?: (edge?: ChannelEdge[] | VideoEdge[]) => (ChannelEdge | VideoEdge)[]
+}
+
+const INITIAL_ROWS = 3
+const INITIAL_CHANNELS_PER_ROW = 1
+
+export const InfiniteChannelWithVideosGrid: FC<InfiniteChannelWithVideosGridProps> = ({
+  onDemand = false,
+  title,
+  skipCount = 0,
+  isReady = true,
+  first,
+  orderBy = ChannelOrderByInput.CreatedAtAsc,
+  className,
+  languageSelector,
+  idIn = null,
+  additionalLink,
+  maximumCount,
+  additionalSortFn,
+}) => {
+  const [selectedLanguage, setSelectedLanguage] = useState<string | null | undefined>('en')
+  const [targetRowsCount, setTargetRowsCount] = useState(INITIAL_ROWS)
+  const fetchMore = useCallback(() => {
+    setTargetRowsCount((prevState) => prevState + 3)
+  }, [])
+
+  const queryVariables: GetChannelsConnectionQueryVariables = {
+    ...(first ? { first } : {}),
+    ...(orderBy ? { orderBy } : {}),
+    where: {
+      ...(selectedLanguage ? { languageId_eq: selectedLanguage } : {}),
+      ...(idIn ? { id_in: idIn } : {}),
+      isPublic_eq: true,
+      isCensored_eq: false,
+    },
+  }
+
+  const { displayedItems, placeholdersCount, loading, error, totalCount } = useInfiniteGrid<
+    GetChannelsConnectionQuery,
+    GetChannelsConnectionQuery['channelsConnection'],
+    GetChannelsConnectionQueryVariables
+  >({
+    query: GetChannelsConnectionDocument,
+    isReady: languageSelector ? isReady && !!selectedLanguage : isReady,
+    skipCount,
+    orderBy,
+    queryVariables,
+    targetRowsCount,
+    dataAccessor: (rawData) => rawData?.channelsConnection,
+    itemsPerRow: INITIAL_CHANNELS_PER_ROW,
+    additionalSortFn,
+    onError: (error) => SentryLogger.error('Failed to fetch channels', 'InfiniteChannelWithVideosGrid', error),
+  })
+
+  const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))
+  const shouldShowLoadMoreButton =
+    onDemand && !loading && (displayedItems.length < totalCount || (maximumCount && totalCount < maximumCount))
+
+  const itemsToShow = [...displayedItems, ...placeholderItems]
+
+  const onSelectLanguage = (value?: string | null) => {
+    setTargetRowsCount(INITIAL_ROWS)
+    setSelectedLanguage(value)
+  }
+
+  if (error) {
+    return null
+  }
+
+  return (
+    <section className={className}>
+      <GridHeadingContainer>
+        {title && (
+          <TitleContainer>
+            {!isReady ? <SkeletonLoader height={23} width={250} /> : <Text variant="h4">{title}</Text>}
+            {languageSelector && (
+              <LanguageSelectWrapper>
+                <Select
+                  items={languages}
+                  value={selectedLanguage}
+                  size="small"
+                  helperText={null}
+                  onChange={onSelectLanguage}
+                />
+              </LanguageSelectWrapper>
+            )}
+            {additionalLink && (
+              <AdditionalLink
+                to={additionalLink.url}
+                size="medium"
+                variant="secondary"
+                iconPlacement="right"
+                icon={<SvgGlyphChevronRight />}
+              >
+                {additionalLink.name}
+              </AdditionalLink>
+            )}
+          </TitleContainer>
+        )}
+      </GridHeadingContainer>
+      {itemsToShow.length ? (
+        itemsToShow.map((channel, idx) => (
+          <Fragment key={`channels-with-videos-${idx}`}>
+            <ChannelWithVideos channelId={channel.id} />
+            {idx + 1 < itemsToShow.length && <Separator />}
+          </Fragment>
+        ))
+      ) : (
+        <>
+          <EmptyFallback
+            title={`No channels found in ${languages.find((language) => language.value === selectedLanguage)?.name}`}
+            variant="large"
+          />
+          <Separator />
+        </>
+      )}
+      {shouldShowLoadMoreButton && (
+        <LoadMoreButtonWrapper>
+          <LoadMoreButton onClick={fetchMore} label="Show more channels" />
+        </LoadMoreButtonWrapper>
+      )}
+    </section>
+  )
+}

+ 23 - 0
src/components/InfiniteGrids/InfiniteGrid.style.ts

@@ -0,0 +1,23 @@
+import styled from '@emotion/styled'
+
+import { Button } from '@/shared/components'
+import { colors, sizes } from '@/shared/theme'
+
+export const LoadMoreButtonWrapper = styled.div`
+  margin-top: ${sizes(12)};
+`
+
+export const LanguageSelectWrapper = styled.div`
+  margin-left: ${sizes(6)};
+  width: ${sizes(34)};
+`
+
+export const Separator = styled.div`
+  height: 1px;
+  background-color: ${colors.gray[700]};
+  margin: ${sizes(16)} 0;
+`
+
+export const AdditionalLink = styled(Button)`
+  margin-left: auto;
+`

+ 58 - 20
src/components/InfiniteGrids/InfiniteVideoGrid.tsx

@@ -1,4 +1,3 @@
-import styled from '@emotion/styled'
 import React, { useCallback, useEffect, useState } from 'react'
 
 import {
@@ -8,15 +7,18 @@ import {
   GetVideosConnectionQueryVariables,
   VideoWhereInput,
 } from '@/api/queries'
-import { Grid, SkeletonLoader, Text } from '@/shared/components'
-import { sizes } from '@/shared/theme'
+import { Grid, GridHeadingContainer, LoadMoreButton, SkeletonLoader, Text, TitleContainer } from '@/shared/components'
+import { SvgGlyphChevronRight } from '@/shared/icons'
+import { SentryLogger } from '@/utils/logs'
 
+import { AdditionalLink, LoadMoreButtonWrapper } from './InfiniteGrid.style'
 import { useInfiniteGrid } from './useInfiniteGrid'
 
 import { VideoTile } from '../VideoTile'
 
 type InfiniteVideoGridProps = {
   title?: string
+  titleLoader?: boolean
   categoryId?: string
   channelId?: string
   channelIdIn?: string[] | null
@@ -25,14 +27,21 @@ type InfiniteVideoGridProps = {
   isCensored?: boolean
   thumbnailPhotoAvailability?: AssetAvailability
   mediaAvailability?: AssetAvailability
+  idIn?: string[]
   skipCount?: number
   ready?: boolean
   showChannel?: boolean
   className?: string
   currentlyWatchedVideoId?: string
+  onDemand?: boolean
+  additionalLink?: {
+    name: string
+    url: string
+  }
+  isFeatured?: boolean
 }
 
-const INITIAL_ROWS = 4
+const INITIAL_ROWS = 2
 const INITIAL_VIDEOS_PER_ROW = 4
 
 export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
@@ -45,11 +54,16 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
   isCensored = false,
   thumbnailPhotoAvailability = AssetAvailability.Accepted,
   mediaAvailability = AssetAvailability.Accepted,
+  idIn,
   skipCount = 0,
   ready = true,
   showChannel = true,
   className,
   currentlyWatchedVideoId,
+  onDemand = false,
+  additionalLink,
+  isFeatured = false,
+  titleLoader,
 }) => {
   const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
   const queryVariables: { where: VideoWhereInput } = {
@@ -60,6 +74,8 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
       ...(categoryId ? { categoryId_eq: categoryId } : {}),
       ...(thumbnailPhotoAvailability ? { thumbnailPhotoAvailability_eq: thumbnailPhotoAvailability } : {}),
       ...(mediaAvailability ? { mediaAvailability_eq: mediaAvailability } : {}),
+      ...(idIn ? { id_in: idIn } : {}),
+      isFeatured_eq: isFeatured,
       isPublic_eq: isPublic,
       isCensored_eq: isCensored,
     },
@@ -72,24 +88,25 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
 
   const targetRowsCount = targetRowsCountByCategory[cachedCategoryId]
 
-  const onScrollToBottom = useCallback(() => {
+  const fetchMore = useCallback(() => {
     setTargetRowsCountByCategory((prevState) => ({
       ...prevState,
       [cachedCategoryId]: targetRowsCount + 2,
     }))
   }, [cachedCategoryId, targetRowsCount])
 
-  const { placeholdersCount, displayedItems, error } = useInfiniteGrid<
+  const { placeholdersCount, displayedItems, error, totalCount, loading } = useInfiniteGrid<
     GetVideosConnectionQuery,
     GetVideosConnectionQuery['videosConnection'],
     GetVideosConnectionQueryVariables
   >({
     query: GetVideosConnectionDocument,
-    onScrollToBottom,
     isReady: ready,
     skipCount,
     queryVariables,
     targetRowsCount,
+    onDemand,
+    onScrollToBottom: !onDemand ? fetchMore : undefined,
     dataAccessor: (rawData) => {
       if (currentlyWatchedVideoId) {
         return (
@@ -103,12 +120,9 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
       return rawData?.videosConnection
     },
     itemsPerRow: videosPerRow,
+    onError: (error) => SentryLogger.error('Failed to fetch videos', 'InfiniteVideoGrid', error),
   })
 
-  if (error) {
-    throw error
-  }
-
   // handle category change
   // TODO potentially move into useInfiniteGrid as a general rule - keep separate targetRowsCount per serialized queryVariables
   useEffect(() => {
@@ -146,24 +160,48 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
     </>
   )
 
+  if (error) {
+    return null
+  }
+
   if (displayedItems.length <= 0 && placeholdersCount <= 0) {
     return null
   }
 
+  const shouldShowLoadMoreButton = onDemand && !loading && displayedItems.length < totalCount
+
   // TODO: We should probably postpone doing first fetch until `onResize` gets called.
   // Right now we'll make the first request and then right after another one based on the resized columns
   return (
     <section className={className}>
-      {title && (!ready ? <StyledSkeletonLoader height={23} width={250} /> : <Title variant="h5">{title}</Title>)}
+      <GridHeadingContainer>
+        {title && (
+          <TitleContainer>
+            {(!ready || !displayedItems.length) && titleLoader ? (
+              <SkeletonLoader height={30} width={250} />
+            ) : (
+              <Text variant="h4">{title}</Text>
+            )}
+            {additionalLink && (
+              <AdditionalLink
+                to={additionalLink.url}
+                size="medium"
+                variant="secondary"
+                iconPlacement="right"
+                icon={<SvgGlyphChevronRight />}
+              >
+                {additionalLink.name}
+              </AdditionalLink>
+            )}
+          </TitleContainer>
+        )}
+      </GridHeadingContainer>
       <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
+      {shouldShowLoadMoreButton && (
+        <LoadMoreButtonWrapper>
+          <LoadMoreButton onClick={fetchMore} />
+        </LoadMoreButtonWrapper>
+      )}
     </section>
   )
 }
-
-const Title = styled(Text)`
-  margin-bottom: ${sizes(4)};
-`
-
-const StyledSkeletonLoader = styled(SkeletonLoader)`
-  margin-bottom: ${sizes(4)};
-`

+ 1 - 0
src/components/InfiniteGrids/index.ts

@@ -1,2 +1,3 @@
 export * from './InfiniteChannelGrid'
 export * from './InfiniteVideoGrid'
+export * from './InfiniteChannelWithVideosGrid'

+ 55 - 11
src/components/InfiniteGrids/useInfiniteGrid.ts

@@ -1,10 +1,12 @@
 import { ApolloError, useQuery } from '@apollo/client'
 import { TypedDocumentNode } from '@graphql-typed-document-node/core'
 import { DocumentNode } from 'graphql'
-import { debounce } from 'lodash'
-import { useEffect } from 'react'
+import { debounce, isEqual } from 'lodash'
+import { useEffect, useRef } from 'react'
 
-type PaginatedData<T> = {
+import { ChannelEdge, ChannelOrderByInput, VideoEdge } from '@/api/queries'
+
+export type PaginatedData<T> = {
   edges: {
     cursor: string
     node: T
@@ -35,14 +37,21 @@ type UseInfiniteGridParams<TRawData, TPaginatedData extends PaginatedData<unknow
   targetRowsCount: number
   itemsPerRow: number
   skipCount: number
-  onScrollToBottom: () => void
+  onError?: (error: unknown) => void
   queryVariables: TArgs
+  onDemand?: boolean
+  onScrollToBottom?: () => void
+  orderBy?: ChannelOrderByInput
+  additionalSortFn?: (edge?: ChannelEdge[] | VideoEdge[]) => (ChannelEdge | VideoEdge)[]
 }
 
 type UseInfiniteGridReturn<TPaginatedData extends PaginatedData<unknown>> = {
   displayedItems: TPaginatedData['edges'][0]['node'][]
   placeholdersCount: number
   error?: ApolloError
+  allItemsLoaded: boolean
+  loading: boolean
+  totalCount: number
 }
 
 export const useInfiniteGrid = <
@@ -57,18 +66,26 @@ export const useInfiniteGrid = <
   itemsPerRow,
   skipCount,
   onScrollToBottom,
+  onError,
   queryVariables,
+  onDemand,
+  orderBy = ChannelOrderByInput.CreatedAtDesc,
+  additionalSortFn,
 }: UseInfiniteGridParams<TRawData, TPaginatedData, TArgs>): UseInfiniteGridReturn<TPaginatedData> => {
   const targetDisplayedItemsCount = targetRowsCount * itemsPerRow
   const targetLoadedItemsCount = targetDisplayedItemsCount + skipCount
 
-  const { loading, data: rawData, error, fetchMore } = useQuery<TRawData, TArgs>(query, {
+  const queryVariablesRef = useRef(queryVariables)
+
+  const { loading, data: rawData, error, fetchMore, refetch } = useQuery<TRawData, TArgs>(query, {
     notifyOnNetworkStatusChange: true,
     skip: !isReady,
     variables: {
       ...queryVariables,
-      first: targetLoadedItemsCount,
+      orderBy,
+      first: additionalSortFn ? 100 : targetDisplayedItemsCount,
     },
+    onError,
   })
 
   const data = dataAccessor(rawData)
@@ -79,7 +96,7 @@ export const useInfiniteGrid = <
 
   // handle fetching more items
   useEffect(() => {
-    if (loading || !isReady || !fetchMore || allItemsLoaded) {
+    if (loading || error || !isReady || !fetchMore || allItemsLoaded) {
       return
     }
 
@@ -92,23 +109,47 @@ export const useInfiniteGrid = <
     fetchMore({
       variables: { ...queryVariables, first: missingItemsCount, after: endCursor },
     })
-  }, [loading, fetchMore, allItemsLoaded, queryVariables, targetLoadedItemsCount, loadedItemsCount, endCursor, isReady])
+  }, [
+    loading,
+    error,
+    fetchMore,
+    allItemsLoaded,
+    queryVariables,
+    targetLoadedItemsCount,
+    loadedItemsCount,
+    endCursor,
+    isReady,
+  ])
+
+  useEffect(() => {
+    if (!isEqual(queryVariablesRef.current, queryVariables)) {
+      queryVariablesRef.current = queryVariables
+      refetch()
+    }
+  }, [queryVariables, refetch])
 
   // handle scroll to bottom
   useEffect(() => {
+    if (onDemand) {
+      return
+    }
+    if (error) return
+
     const scrollHandler = debounce(() => {
       const scrolledToBottom =
         window.innerHeight + document.documentElement.scrollTop >= document.documentElement.offsetHeight
-      if (scrolledToBottom && isReady && !loading && !allItemsLoaded) {
+      if (onScrollToBottom && scrolledToBottom && isReady && !loading && !allItemsLoaded) {
         onScrollToBottom()
       }
     }, 100)
 
     window.addEventListener('scroll', scrollHandler)
     return () => window.removeEventListener('scroll', scrollHandler)
-  }, [isReady, loading, allItemsLoaded, onScrollToBottom])
+  }, [error, isReady, loading, allItemsLoaded, onScrollToBottom, onDemand])
+
+  const edges = additionalSortFn ? additionalSortFn(data?.edges as ChannelEdge[] | VideoEdge[]) : data?.edges
 
-  const displayedEdges = data?.edges.slice(skipCount, targetLoadedItemsCount) ?? []
+  const displayedEdges = edges?.slice(skipCount, targetLoadedItemsCount) ?? []
   const displayedItems = displayedEdges.map((edge) => edge.node)
 
   const displayedItemsCount = data
@@ -119,6 +160,9 @@ export const useInfiniteGrid = <
   return {
     displayedItems,
     placeholdersCount,
+    allItemsLoaded,
     error,
+    loading,
+    totalCount: data?.totalCount || 0,
   }
 }

+ 2 - 2
src/components/InterruptedVideosGallery.tsx

@@ -3,7 +3,7 @@ import React from 'react'
 
 import { VideoGallery } from '@/components'
 import { usePersonalDataStore } from '@/providers'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 const INTERRUPTED_VIDEOS_COUNT = 16
 
@@ -24,7 +24,7 @@ export const InterruptedVideosGallery: React.FC<RouteComponentProps> = () => {
   }
 
   const onVideoNotFound = (id: string) => {
-    Logger.warn(`Interrupted video not found, removing id: ${id}`)
+    ConsoleLogger.warn(`Interrupted video not found, removing id: ${id}`)
     updateWatchedVideos('REMOVED', id)
   }
 

+ 6 - 3
src/components/LimitedWidthContainer.tsx

@@ -1,13 +1,16 @@
 import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 
-export const limitedWidthContainerStyle = css`
-  --max-inner-width: calc(1440px - var(--sidenav-collapsed-width) - calc(2 * var(--global-horizontal-padding)));
+type LimitedWidthContainerProps = { big?: boolean }
+
+export const limitedWidthContainerStyle = (props: LimitedWidthContainerProps) => css`
+  --max-inner-width: calc(${props.big ? '2284' : '1440'}px - calc(2 * var(--global-horizontal-padding)));
 
   max-width: var(--max-inner-width);
   position: relative;
   margin: 0 auto;
 `
-export const LimitedWidthContainer = styled.div`
+
+export const LimitedWidthContainer = styled.div<LimitedWidthContainerProps>`
   ${limitedWidthContainerStyle}
 `

+ 23 - 0
src/components/OfficialJoystreamUpdate.tsx

@@ -0,0 +1,23 @@
+import React from 'react'
+
+import { useVideos } from '@/api/hooks'
+import { VideoGallery } from '@/components'
+import { readEnv } from '@/config/envs'
+
+const channelId = readEnv('OFFICIAL_JOYSTREAM_CHANNEL_ID')
+const MAX_VIDEOS = 10
+
+export const OfficialJoystreamUpdate = () => {
+  const { videos, loading } = useVideos({
+    where: {
+      channelId_eq: channelId,
+    },
+    limit: MAX_VIDEOS,
+  })
+
+  return (
+    <section>
+      <VideoGallery title="Official Joystream updates" videos={videos || []} loading={loading} />
+    </section>
+  )
+}

+ 31 - 0
src/components/PromisingNewChannels.tsx

@@ -0,0 +1,31 @@
+import React from 'react'
+
+import { ChannelEdge, ChannelOrderByInput, VideoEdge } from '@/api/queries'
+import { absoluteRoutes } from '@/config/routes'
+
+import { InfiniteChannelWithVideosGrid } from './InfiniteGrids'
+
+export const PromisingNewChannels = () => {
+  const sortChannelsByViewsDesc = (edges?: ChannelEdge[] | VideoEdge[]) => {
+    if (!edges) {
+      return []
+    }
+    return [...edges].sort((a, b) => {
+      return (b.node.views || 0) - (a.node.views || 0)
+    })
+  }
+
+  return (
+    <InfiniteChannelWithVideosGrid
+      title="Promising new channels"
+      onDemand
+      first={100}
+      orderBy={ChannelOrderByInput.CreatedAtDesc}
+      additionalLink={{
+        name: 'Browse Channels',
+        url: absoluteRoutes.viewer.channels(),
+      }}
+      additionalSortFn={sortChannelsByViewsDesc}
+    />
+  )
+}

+ 11 - 6
src/components/Sidenav/ViewerSidenav/ViewerSidenav.tsx

@@ -4,9 +4,9 @@ import { NavItemType, SidenavBase } from '@/components/Sidenav/SidenavBase'
 import { absoluteRoutes } from '@/config/routes'
 import { usePersonalDataStore } from '@/providers'
 import { Button } from '@/shared/components'
-import { SvgGlyphExternal, SvgNavChannels, SvgNavHome, SvgNavVideos } from '@/shared/icons'
+import { SvgGlyphExternal, SvgNavChannels, SvgNavHome, SvgNavNew, SvgNavPopular } from '@/shared/icons'
 import { openInNewTab } from '@/utils/browser'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { FollowedChannels } from './FollowedChannels'
 
@@ -17,9 +17,14 @@ const viewerSidenavItems: NavItemType[] = [
     to: absoluteRoutes.viewer.index(),
   },
   {
-    icon: <SvgNavVideos />,
-    name: 'Videos',
-    to: absoluteRoutes.viewer.videos(),
+    icon: <SvgNavPopular />,
+    name: 'Popular',
+    to: absoluteRoutes.viewer.popular(),
+  },
+  {
+    icon: <SvgNavNew />,
+    name: 'New',
+    to: absoluteRoutes.viewer.new(),
   },
   {
     icon: <SvgNavChannels />,
@@ -34,7 +39,7 @@ export const ViewerSidenav: React.FC = () => {
   const updateChannelFollowing = usePersonalDataStore((state) => state.actions.updateChannelFollowing)
 
   const handleChannelNotFound = (id: string) => {
-    Logger.warn(`Followed channel not found, removing id: ${id}`)
+    ConsoleLogger.warn(`Followed channel not found, removing id: ${id}`)
     updateChannelFollowing(id, false)
   }
 

+ 15 - 0
src/components/TopTenChannels.tsx

@@ -0,0 +1,15 @@
+import React from 'react'
+
+import { useMostFollowedChannelsAllTime } from '@/api/hooks'
+
+import { ChannelGallery } from './ChannelGallery'
+
+export const TopTenChannels = () => {
+  const { channels, loading } = useMostFollowedChannelsAllTime({ limit: 10 })
+
+  return (
+    <section>
+      <ChannelGallery hasRanking channels={channels} loading={loading} title="Top 10 Channels" />
+    </section>
+  )
+}

+ 14 - 0
src/components/TopTenThisWeek.tsx

@@ -0,0 +1,14 @@
+import React from 'react'
+
+import { useMostViewedVideos } from '@/api/hooks'
+import { VideoGallery } from '@/components'
+
+export const TopTenThisWeek = () => {
+  const { videos, loading } = useMostViewedVideos({ limit: 10, timePeriodDays: 7 })
+
+  return (
+    <section>
+      <VideoGallery title="Top 10 this week" videos={videos} loading={loading} hasRanking />
+    </section>
+  )
+}

+ 79 - 52
src/components/VideoGallery.tsx

@@ -1,10 +1,11 @@
 import styled from '@emotion/styled'
-import React, { useCallback, useMemo, useState } from 'react'
+import React, { useMemo } from 'react'
 
 import { VideoFieldsFragment } from '@/api/queries'
-import { CAROUSEL_ARROW_HEIGHT, Gallery, MIN_VIDEO_TILE_WIDTH } from '@/shared/components'
+import { Gallery, RankingNumberTile } from '@/shared/components'
 import { breakpointsOfGrid } from '@/shared/components/Grid'
-import { sizes } from '@/shared/theme'
+import { AvatarContainer } from '@/shared/components/VideoTileBase/VideoTileBase.styles'
+import { media } from '@/shared/theme'
 
 import { VideoTile } from './VideoTile'
 
@@ -21,29 +22,22 @@ type CustomVideosType = VideoFieldsWithProgress[] | VideoWithIdAndProgress[]
 
 type VideoGalleryProps = {
   title?: string
-  videos?: CustomVideosType
+  videos?: CustomVideosType | null
   loading?: boolean
   removeButton?: boolean
   onRemoveButtonClick?: (id: string) => void
   onVideoNotFound?: (id: string) => void
   onVideoClick?: (id: string) => void
+  hasRanking?: boolean
+  seeAllUrl?: string
+  className?: string
 }
 
 const PLACEHOLDERS_COUNT = 12
-
-// This is needed since Gliderjs and the Grid have different resizing policies
-const breakpoints = breakpointsOfGrid({
-  breakpoints: 6,
-  minItemWidth: 300,
-  gridColumnGap: 24,
-  viewportContainerDifference: 64,
-}).map((breakpoint, idx) => ({
-  breakpoint,
-  settings: {
-    slidesToShow: idx + 1,
-    slidesToScroll: idx + 1,
-  },
-}))
+const MIN_VIDEO_PREVIEW_WIDTH = 281
+const CAROUSEL_SMALL_BREAKPOINT = 688
+const FRACTIONAL_LEVEL = 1.3
+const FRACTIONAL_LEVEL_RANKING = 1.4
 
 export const VideoGallery: React.FC<VideoGalleryProps> = ({
   title,
@@ -53,23 +47,41 @@ export const VideoGallery: React.FC<VideoGalleryProps> = ({
   removeButton,
   onRemoveButtonClick,
   onVideoNotFound,
+  seeAllUrl,
+  hasRanking = false,
+  className,
 }) => {
-  const [coverHeight, setCoverHeight] = useState<number>()
-  const onCoverResize = useCallback((_, imgHeight) => {
-    setCoverHeight(imgHeight)
-  }, [])
-  const arrowPosition = useMemo(() => {
-    if (!coverHeight) {
-      return
-    }
-    const topPx = (coverHeight - CAROUSEL_ARROW_HEIGHT) / 2
-    return topPx
-  }, [coverHeight])
+  const breakpoints = useMemo(() => {
+    return breakpointsOfGrid({
+      breakpoints: 6,
+      minItemWidth: 300,
+      gridColumnGap: 24,
+      viewportContainerDifference: 64,
+    }).map((breakpoint, idx) => {
+      if (breakpoint <= CAROUSEL_SMALL_BREAKPOINT && hasRanking) {
+        return {
+          breakpoint,
+          settings: {
+            slidesToShow: idx + FRACTIONAL_LEVEL,
+            slidesToScroll: idx + 1,
+          },
+        }
+      }
+      return {
+        breakpoint,
+        settings: {
+          slidesToShow: idx + (breakpoint <= CAROUSEL_SMALL_BREAKPOINT ? FRACTIONAL_LEVEL_RANKING : 1),
+          slidesToScroll: idx + 1,
+        },
+      }
+    })
+  }, [hasRanking])
 
-  if (!loading && videos?.length === 0) {
+  if (loading === false && videos?.length === 0) {
     return null
   }
-  const placeholderItems = Array.from({ length: loading ? PLACEHOLDERS_COUNT : 0 }, () => ({
+
+  const placeholderItems = Array.from({ length: loading || !videos?.length ? PLACEHOLDERS_COUNT : 0 }, () => ({
     id: undefined,
     progress: undefined,
   }))
@@ -79,33 +91,48 @@ export const VideoGallery: React.FC<VideoGalleryProps> = ({
   return (
     <Gallery
       title={title}
-      paddingLeft={sizes(2, true)}
-      paddingTop={sizes(2, true)}
       responsive={breakpoints}
-      itemWidth={MIN_VIDEO_TILE_WIDTH}
-      arrowPosition={arrowPosition}
+      itemWidth={MIN_VIDEO_PREVIEW_WIDTH}
+      dotsVisible
+      seeAllUrl={seeAllUrl}
+      className={className}
     >
-      {[...videos, ...placeholderItems]?.map((video, idx) => (
-        <StyledVideoTile
-          id={video.id}
-          progress={video?.progress}
-          key={idx}
-          removeButton={video ? removeButton : false}
-          onCoverResize={onCoverResize}
-          onClick={createClickHandler(video.id)}
-          onNotFound={createNotFoundHandler(video.id)}
-          onRemoveButtonClick={createRemoveButtonClickHandler(video.id)}
-        />
-      ))}
+      {[...(videos ? videos : []), ...placeholderItems]?.map((video, idx) =>
+        hasRanking ? (
+          <RankingNumberTile variant="video" rankingNumber={idx + 1} key={`${idx}-${video.id}`}>
+            <StyledVideoTile
+              id={video.id}
+              progress={video?.progress}
+              removeButton={video ? removeButton : false}
+              onClick={createClickHandler(video.id)}
+              onNotFound={createNotFoundHandler(video.id)}
+              onRemoveButtonClick={createRemoveButtonClickHandler(video.id)}
+            />
+          </RankingNumberTile>
+        ) : (
+          <StyledVideoTile
+            key={`${idx}-${video.id}`}
+            id={video.id}
+            progress={video?.progress}
+            removeButton={video ? removeButton : false}
+            onClick={createClickHandler(video.id)}
+            onNotFound={createNotFoundHandler(video.id)}
+            onRemoveButtonClick={createRemoveButtonClickHandler(video.id)}
+          />
+        )
+      )}
     </Gallery>
   )
 }
 
 const StyledVideoTile = styled(VideoTile)`
-  & + & {
-    margin-left: ${sizes(6)};
-  }
+  flex-shrink: 0;
 
-  /* MIN_VIDEO_TILE_WIDTH */
-  min-width: 300px;
+  ${AvatarContainer} {
+    display: none;
+
+    ${media.medium} {
+      display: block;
+    }
+  }
 `

+ 41 - 138
src/components/VideoHero/VideoHero.style.ts

@@ -1,48 +1,18 @@
 import { css } from '@emotion/react'
 import styled from '@emotion/styled'
-import { fluidRange } from 'polished'
 
-import { Button, IconButton, SkeletonLoader, Text } from '@/shared/components'
-import { breakpoints, colors, media, sizes } from '@/shared/theme'
+import { IconButton, SkeletonLoader, Text } from '@/shared/components'
+import { colors, media, sizes, typography } from '@/shared/theme'
 
+import { TOP_NAVBAR_HEIGHT } from '..'
 import { ChannelLink } from '../ChannelLink'
 
-const CONTENT_OVERLAP_MAP = {
-  SMALL: 25,
-  MEDIUM: 150,
-  LARGE: 200,
-  XLARGE: 400,
-  XXLARGE: 600,
-}
-const GRADIENT_OVERLAP = 50
-const GRADIENT_HEIGHT = 250
-const INFO_BOTTOM_MARGIN = 100
-const BUTTONS_HEIGHT_PX = '54px'
+const BUTTONS_HEIGHT = 48
 
 export const Container = styled.section`
   position: relative;
-
-  /* because of the fixed aspect ratio, as the viewport width grows, the media will occupy more height as well
-   so that the media doesn't take too big of a portion of the space, we let the content overlap the media via a negative margin */
-  ${media.small} {
-    margin-bottom: -${CONTENT_OVERLAP_MAP.SMALL}px;
-  }
-
-  ${media.medium} {
-    margin-bottom: -${CONTENT_OVERLAP_MAP.MEDIUM}px;
-  }
-
-  ${media.large} {
-    margin-bottom: -${CONTENT_OVERLAP_MAP.LARGE}px;
-  }
-
-  ${media.xlarge} {
-    margin-bottom: -${CONTENT_OVERLAP_MAP.XLARGE}px;
-  }
-
-  ${media.xxlarge} {
-    margin-bottom: -${CONTENT_OVERLAP_MAP.XXLARGE}px;
-  }
+  max-height: calc(100vh - ${TOP_NAVBAR_HEIGHT}px);
+  margin-bottom: -${sizes(6)}px;
 `
 
 export const MediaWrapper = styled.div`
@@ -53,8 +23,18 @@ export const MediaWrapper = styled.div`
 export const Media = styled.div`
   width: 100%;
   height: 0;
-  padding-top: 56.25%;
+
+  /* 2:1 ratio */
+  padding-top: 50%;
   position: relative;
+
+  ${media.smalldium} {
+    margin-bottom: ${sizes(8)}px;
+  }
+
+  ${media.medium} {
+    margin-bottom: 0;
+  }
 `
 
 const absoluteMediaCss = css`
@@ -69,95 +49,36 @@ export const PlayerContainer = styled.div`
   ${absoluteMediaCss};
 `
 
-export const HorizontalGradientOverlay = styled.div`
+export const GradientOverlay = styled.div`
   ${absoluteMediaCss};
 
-  display: none;
-  background: linear-gradient(90deg, rgba(0, 0, 0, 0.8) 11.76%, rgba(0, 0, 0, 0) 100%);
-
-  ${media.small} {
-    display: block;
-  }
-`
-
-export const VerticalGradientOverlay = styled.div`
-  ${absoluteMediaCss};
-
-  /* as the content overlaps the media more and more as the viewport width grows, we need to hide some part of the media with a gradient
-   this helps with keeping a consistent background behind a page content - we don't want the media to peek out in the content spacing */
-  background: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) ${GRADIENT_HEIGHT / 2}px);
-  ${media.small} {
-    background: linear-gradient(
-      0deg,
-      black 0%,
-      black ${CONTENT_OVERLAP_MAP.SMALL - GRADIENT_OVERLAP}px,
-      rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.SMALL - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-    );
-  }
-
-  ${media.medium} {
-    background: linear-gradient(
-      0deg,
-      black 0%,
-      black ${CONTENT_OVERLAP_MAP.MEDIUM - GRADIENT_OVERLAP}px,
-      rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.MEDIUM - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-    );
-  }
-
-  ${media.large} {
-    background: linear-gradient(
-      0deg,
-      black 0%,
-      black ${CONTENT_OVERLAP_MAP.LARGE - GRADIENT_OVERLAP}px,
-      rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.LARGE - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-    );
-  }
-
-  ${media.xlarge} {
-    background: linear-gradient(
-      0deg,
-      black 0%,
-      black ${CONTENT_OVERLAP_MAP.XLARGE - GRADIENT_OVERLAP}px,
-      rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.XLARGE - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-    );
-  }
-
-  ${media.xxlarge} {
-    background: linear-gradient(
-      0deg,
-      black 0%,
-      black ${CONTENT_OVERLAP_MAP.XXLARGE - GRADIENT_OVERLAP}px,
-      rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.XXLARGE - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-    );
-  }
+  background: linear-gradient(180deg, transparent 50%, ${colors.black} 93.23%, ${colors.black} 100%),
+    radial-gradient(50.66% 101.32% at 50% 50%, transparent 0%, ${colors.transparentBlack[54]} 100%),
+    ${colors.transparentBlack[54]};
 `
 
 export const InfoContainer = styled.div<{ isLoading: boolean }>`
   position: relative;
-  margin-top: -${sizes(8)};
-  padding-bottom: ${sizes(12)};
+  padding-bottom: ${sizes(16)};
+  width: 100%;
 
   ${media.small} {
+    margin-bottom: 64px;
+    padding-bottom: 0;
+  }
+
+  ${media.smalldium} {
     position: absolute;
     margin: 0;
-    padding-bottom: 0;
-    bottom: ${CONTENT_OVERLAP_MAP.SMALL + INFO_BOTTOM_MARGIN / 4}px;
+    bottom: 64px;
   }
 
   ${media.medium} {
-    bottom: ${CONTENT_OVERLAP_MAP.MEDIUM + INFO_BOTTOM_MARGIN / 2}px;
+    bottom: 96px;
   }
 
   ${media.large} {
-    bottom: ${CONTENT_OVERLAP_MAP.LARGE + INFO_BOTTOM_MARGIN}px;
-  }
-
-  ${media.xlarge} {
-    bottom: ${CONTENT_OVERLAP_MAP.XLARGE + INFO_BOTTOM_MARGIN}px;
-  }
-
-  ${media.xxlarge} {
-    bottom: ${CONTENT_OVERLAP_MAP.XXLARGE + INFO_BOTTOM_MARGIN}px;
+    bottom: 128px;
   }
 `
 
@@ -170,31 +91,20 @@ export const TitleContainer = styled.div`
     text-decoration: none;
   }
 
-  margin-bottom: ${sizes(8)};
-
-  ${media.medium} {
-    margin-bottom: ${sizes(10)};
-  }
-
-  span {
-    display: block;
-    max-width: 40ch;
-    ${fluidRange({ prop: 'fontSize', fromSize: '14px', toSize: '22px' }, breakpoints.base, breakpoints.xlarge)};
-    ${fluidRange({ prop: 'lineHeight', fromSize: '20px', toSize: '26px' }, breakpoints.base, breakpoints.xlarge)};
+  margin-bottom: ${sizes(4)};
 
-    color: ${colors.white};
+  ${media.small} {
+    margin-bottom: ${sizes(8)};
   }
 `
 
 export const Title = styled(Text)`
-  ${fluidRange({ prop: 'fontSize', fromSize: '40px', toSize: '72px' }, breakpoints.base, breakpoints.xlarge)};
-  ${fluidRange({ prop: 'lineHeight', fromSize: '48px', toSize: '68px' }, breakpoints.base, breakpoints.xlarge)};
+  font-size: ${typography.sizes.h3};
+  line-height: ${typography.lineHeights.h3};
 
-  display: inline-block;
-  margin-bottom: ${sizes(4)};
-
-  ${media.medium} {
-    margin-bottom: ${sizes(5)};
+  ${media.large} {
+    font-size: ${typography.sizes.h2};
+    line-height: ${typography.lineHeights.h2};
   }
 `
 
@@ -206,21 +116,14 @@ export const TitleSkeletonLoader = styled(SkeletonLoader)`
   }
 `
 
-export const ControlsContainer = styled.div`
-  min-height: ${BUTTONS_HEIGHT_PX};
-`
-
 export const ButtonsContainer = styled.div`
   display: flex;
 `
 
-export const PlayButton = styled(Button)`
-  width: 140px;
-  height: ${BUTTONS_HEIGHT_PX};
+export const ButtonsSpaceKeeper = styled.div`
+  min-height: ${BUTTONS_HEIGHT}px;
 `
 
 export const SoundButton = styled(IconButton)`
   margin-left: ${sizes(4)};
-  height: ${BUTTONS_HEIGHT_PX};
-  width: ${BUTTONS_HEIGHT_PX};
 `

+ 39 - 41
src/components/VideoHero/VideoHero.tsx

@@ -4,26 +4,26 @@ import { CSSTransition } from 'react-transition-group'
 
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, useAsset } from '@/providers'
-import { SkeletonLoader, VideoPlayer } from '@/shared/components'
-import { SvgPlayerPause, SvgPlayerPlay, SvgPlayerSoundOff, SvgPlayerSoundOn } from '@/shared/icons'
+import { Button, GridItem, LayoutGrid, SkeletonLoader, VideoPlayer } from '@/shared/components'
+import { SvgActionPlay } from '@/shared/icons/ActionPlay'
+import { SvgActionSoundOff } from '@/shared/icons/ActionSoundOff'
+import { SvgActionSoundOn } from '@/shared/icons/ActionSoundOn'
 import { transitions } from '@/shared/theme'
 
 import {
   ButtonsContainer,
+  ButtonsSpaceKeeper,
   Container,
-  ControlsContainer,
-  HorizontalGradientOverlay,
+  GradientOverlay,
   InfoContainer,
   Media,
   MediaWrapper,
-  PlayButton,
   PlayerContainer,
   SoundButton,
   StyledChannelLink,
   Title,
   TitleContainer,
   TitleSkeletonLoader,
-  VerticalGradientOverlay,
 } from './VideoHero.style'
 import { useVideoHero } from './VideoHeroData'
 
@@ -47,10 +47,6 @@ export const VideoHero: React.FC = () => {
     }, VIDEO_PLAYBACK_DELAY)
   }
 
-  const handlePlayPauseClick = () => {
-    setVideoPlaying(!videoPlaying)
-  }
-
   const handleSoundToggleClick = () => {
     setSoundMuted(!soundMuted)
   }
@@ -62,44 +58,50 @@ export const VideoHero: React.FC = () => {
           <PlayerContainer>
             {coverVideo && (
               <VideoPlayer
+                videoStyle={{ objectFit: 'cover' }}
                 fluid
                 isInBackground
                 muted={soundMuted}
                 playing={videoPlaying}
                 posterUrl={thumbnailPhotoUrl}
                 onDataLoaded={handlePlaybackDataLoaded}
+                onPlay={() => setVideoPlaying(true)}
+                onPause={() => setVideoPlaying(false)}
+                onEnd={() => setVideoPlaying(false)}
                 src={coverVideo?.coverCutMediaUrl}
               />
             )}
           </PlayerContainer>
-          {coverVideo && <HorizontalGradientOverlay />}
-          <VerticalGradientOverlay />
+          <GradientOverlay />
         </Media>
       </MediaWrapper>
       <InfoContainer isLoading={!coverVideo}>
         <StyledChannelLink
+          variant="secondary"
           id={coverVideo?.video.channel.id}
-          hideHandle
           overrideChannel={coverVideo?.video.channel}
-          avatarSize="cover"
+          avatarSize="small"
         />
-        <TitleContainer>
-          {coverVideo ? (
-            <>
-              <Link to={absoluteRoutes.viewer.video(coverVideo.video.id)}>
-                <Title variant="h2">{coverVideo.coverTitle}</Title>
-              </Link>
-              <span>{coverVideo.coverDescription}</span>
-            </>
-          ) : (
-            <>
-              <TitleSkeletonLoader width={380} height={60} />
-              <SkeletonLoader width={300} height={20} bottomSpace={4} />
-              <SkeletonLoader width={200} height={20} />
-            </>
-          )}
-        </TitleContainer>
-        <ControlsContainer>
+        <LayoutGrid>
+          <GridItem colSpan={{ base: 12, compact: 10, small: 8, medium: 5, xlarge: 4, xxlarge: 3 }}>
+            <TitleContainer>
+              {coverVideo ? (
+                <>
+                  <Link to={absoluteRoutes.viewer.video(coverVideo.video.id)}>
+                    <Title variant="h2">{coverVideo.coverTitle}</Title>
+                  </Link>
+                </>
+              ) : (
+                <>
+                  <TitleSkeletonLoader width={380} height={60} />
+                  <SkeletonLoader width={300} height={20} bottomSpace={4} />
+                  <SkeletonLoader width={200} height={20} />
+                </>
+              )}
+            </TitleContainer>
+          </GridItem>
+        </LayoutGrid>
+        <ButtonsSpaceKeeper>
           <CSSTransition
             in={displayControls}
             timeout={parseInt(transitions.timings.loading)}
@@ -108,19 +110,15 @@ export const VideoHero: React.FC = () => {
             appear
           >
             <ButtonsContainer>
-              <PlayButton
-                onClick={handlePlayPauseClick}
-                icon={videoPlaying ? <SvgPlayerPause /> : <SvgPlayerPlay />}
-                size="large"
-              >
-                {videoPlaying ? 'Pause' : 'Play'}
-              </PlayButton>
-              <SoundButton onClick={handleSoundToggleClick} size="large">
-                {!soundMuted ? <SvgPlayerSoundOn /> : <SvgPlayerSoundOff />}
+              <Button to={absoluteRoutes.viewer.video(coverVideo ? coverVideo.video.id : '')} icon={<SvgActionPlay />}>
+                Play
+              </Button>
+              <SoundButton variant="secondary" onClick={handleSoundToggleClick}>
+                {!soundMuted ? <SvgActionSoundOn /> : <SvgActionSoundOff />}
               </SoundButton>
             </ButtonsContainer>
           </CSSTransition>
-        </ControlsContainer>
+        </ButtonsSpaceKeeper>
       </InfoContainer>
     </Container>
   )

+ 11 - 7
src/components/VideoHero/VideoHeroData.ts

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
 import { useVideo } from '@/api/hooks/video'
 import { VideoFieldsFragment } from '@/api/queries'
 import { COVER_VIDEO_INFO_URL } from '@/config/urls'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import backupVideoHeroInfo from './backupVideoHeroInfo.json'
 
@@ -23,11 +23,13 @@ type CoverInfo =
 
 export const useVideoHero = (): CoverInfo => {
   const [fetchedCoverInfo, setFetchedCoverInfo] = useState<RawCoverInfo | null>(null)
-  const { video, error } = useVideo(fetchedCoverInfo?.videoId || '', { skip: !fetchedCoverInfo?.videoId })
-
-  if (error) {
-    throw error
-  }
+  const { video } = useVideo(fetchedCoverInfo?.videoId || '', {
+    skip: !fetchedCoverInfo?.videoId,
+    onError: (error) =>
+      SentryLogger.error('Failed to fetch video hero', 'VideoHero', error, {
+        video: { id: fetchedCoverInfo?.videoId },
+      }),
+  })
 
   useEffect(() => {
     const fetchInfo = async () => {
@@ -35,7 +37,9 @@ export const useVideoHero = (): CoverInfo => {
         const response = await axios.get<RawCoverInfo>(COVER_VIDEO_INFO_URL)
         setFetchedCoverInfo(response.data)
       } catch (e) {
-        Logger.error(`Failed to fetch cover info from ${COVER_VIDEO_INFO_URL}. Using backup`, e)
+        SentryLogger.error('Failed to fetch video hero info', 'VideoHero', e, {
+          videoHero: { url: COVER_VIDEO_INFO_URL },
+        })
         setFetchedCoverInfo(backupVideoHeroInfo)
       }
     }

+ 3 - 2
src/components/VideoTile.tsx

@@ -11,7 +11,7 @@ import {
   VideoTilePublisherProps,
 } from '@/shared/components/VideoTileBase/VideoTileBase'
 import { copyToClipboard, openInNewTab } from '@/utils/browser'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 export type VideoTileProps = {
   id?: string
@@ -41,6 +41,7 @@ export const VideoTile: React.FC<VideoTileProps> = ({ id, onNotFound, ...metaPro
       views={video?.views}
       videoHref={videoHref}
       channelHref={id ? absoluteRoutes.viewer.channel(video?.channel.id) : undefined}
+      onCopyVideoURLClick={() => copyToClipboard(videoHref ? location.origin + videoHref : '')}
       thumbnailUrl={thumbnailPhotoUrl}
       isLoading={loading}
       contentKey={id}
@@ -97,7 +98,7 @@ const useVideoSharedLogic = ({ id, isDraft, onNotFound }: UseVideoSharedLogicOpt
   const { video, loading } = useVideo(id ?? '', {
     skip: !id || isDraft,
     onCompleted: (data) => !data && onNotFound?.(),
-    onError: (error) => Logger.error('Failed to fetch video', error),
+    onError: (error) => SentryLogger.error('Failed to fetch video', 'VideoTile', error, { video: { id } }),
   })
   const internalIsLoadingState = loading || !id
   const videoHref = id ? absoluteRoutes.viewer.video(id) : undefined

+ 58 - 25
src/components/ViewErrorFallback.tsx

@@ -1,11 +1,54 @@
 import styled from '@emotion/styled'
 import { FallbackRender } from '@sentry/react/dist/errorboundary'
 import React from 'react'
+import { useNavigate } from 'react-router-dom'
 
-import { Button, Text } from '@/shared/components'
-import { SvgWellErrorIllustration } from '@/shared/illustrations'
-import { colors, sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { absoluteRoutes } from '@/config/routes'
+import { JOYSTREAM_DISCORD_URL } from '@/config/urls'
+import { AnimatedError, Button, Text } from '@/shared/components'
+import { media, sizes } from '@/shared/theme'
+import { SentryLogger } from '@/utils/logs'
+
+// this isn't a react component, just a function that will be executed once to get a react element
+export const ViewErrorBoundary: FallbackRender = ({ error, resetError }) => {
+  SentryLogger.error('Unhandled exception was thrown', 'ErrorBoundary', error)
+  return <ViewErrorFallback onResetClick={resetError} />
+}
+
+type ViewErrorFallbackProps = {
+  onResetClick?: () => void
+}
+
+export const ViewErrorFallback: React.FC<ViewErrorFallbackProps> = ({ onResetClick }) => {
+  const navigate = useNavigate()
+
+  const handleResetClick = () => {
+    if (onResetClick) {
+      onResetClick()
+    } else {
+      navigate(absoluteRoutes.viewer.index())
+    }
+  }
+
+  return (
+    <Container>
+      <AnimatedError />
+      <Message>
+        <Header variant="h3">Oops! An error occurred.</Header>
+        <Text variant="body1" secondary>
+          Something bad happened and the app broke. This has been logged and we&apos;ll try to resolve it as soon as
+          possible. You can find support in our Discord community.
+        </Text>
+      </Message>
+      <ButtonsContainer>
+        <Button to={JOYSTREAM_DISCORD_URL} variant="secondary">
+          Open Discord
+        </Button>
+        <Button onClick={handleResetClick}>Return to home page</Button>
+      </ButtonsContainer>
+    </Container>
+  )
+}
 
 const Container = styled.div`
   margin: ${sizes(20)} auto 0;
@@ -21,29 +64,19 @@ const Message = styled.div`
   display: flex;
   flex-direction: column;
   text-align: center;
-  margin-top: 90px;
-  margin-bottom: ${sizes(10)};
+  margin-top: 50px;
+  ${media.small} {
+    max-width: 70%;
+  }
 `
 
-const Title = styled(Text)`
-  line-height: 1.25;
+const Header = styled(Text)`
+  margin-bottom: ${sizes(2)};
 `
 
-const Subtitle = styled(Text)`
-  line-height: 1.75;
-  color: ${colors.gray[300]};
+const ButtonsContainer = styled.div`
+  margin-top: 50px;
+  display: grid;
+  grid-template-columns: auto auto;
+  grid-gap: 16px;
 `
-
-export const ErrorFallback: FallbackRender = ({ error, componentStack, resetError }) => {
-  Logger.error('An error occurred.', { componentStack, error })
-  return (
-    <Container>
-      <SvgWellErrorIllustration />
-      <Message>
-        <Title variant="h3">Oops! An Error occurred.</Title>
-        <Subtitle>We could not acquire expected results. Please try reloading or return to the home page.</Subtitle>
-      </Message>
-      <Button onClick={resetError}>Return to home page</Button>
-    </Container>
-  )
-}

+ 6 - 3
src/components/index.ts

@@ -8,13 +8,11 @@ export * from './SkeletonLoaderVideoGrid'
 export * from './VideoTile'
 export * from './ChannelCard'
 export * from './ChannelGrid'
-export { ErrorFallback as ViewErrorFallback } from './ViewErrorFallback'
-export * from './ErrorFallback'
+export * from './ViewErrorFallback'
 export * from './ChannelLink'
 export * from './BackgroundPattern'
 export * from './InfiniteGrids'
 export * from './Sidenav'
-
 export * from './InterruptedVideosGallery'
 export * from './ViewWrapper'
 export * from './Portal'
@@ -25,3 +23,8 @@ export * from './NoConnectionIndicator'
 export * from './SignInSteps'
 export * from './StudioEntrypoint'
 export * from './PrivateRoute'
+export * from './OfficialJoystreamUpdate'
+export * from './TopTenThisWeek'
+export * from './TopTenChannels'
+export * from './ChannelWithVideos'
+export * from './DiscoverChannels'

+ 2 - 1
src/config/routes.ts

@@ -19,11 +19,12 @@ const withQueryParameters = (basePath: string, query: Record<string, string> = {
 export const relativeRoutes = {
   viewer: {
     index: () => '',
+    new: () => 'new',
+    popular: () => 'popular',
     search: (query?: { query?: string }) => withQueryParameters('search', query),
     channel: (id = ':id') => `channel/${id}`,
     channels: () => 'channels',
     video: (id = ':id') => `video/${id}`,
-    videos: () => 'videos',
   },
   legal: {
     index: () => '',

+ 1 - 0
src/config/urls.ts

@@ -3,6 +3,7 @@ import { readEnv } from './envs'
 export const QUERY_NODE_GRAPHQL_URL = readEnv('QUERY_NODE_URL')
 export const QUERY_NODE_GRAPHQL_SUBSCRIPTION_URL = readEnv('QUERY_NODE_SUBSCRIPTION_URL')
 export const ORION_GRAPHQL_URL = readEnv('ORION_URL')
+export const ASSET_LOGS_URL = readEnv('ASSET_LOGS_URL', false)
 export const NODE_URL = readEnv('NODE_URL')
 export const FAUCET_URL = readEnv('FAUCET_URL')
 

+ 1 - 0
src/hooks/index.ts

@@ -3,3 +3,4 @@ export * from './useContextMenu'
 export * from './useCheckBrowser'
 export * from './useDeleteVideo'
 export * from './useDisplayDataLostWarning'
+export * from './useHandleFollowChannel'

+ 31 - 0
src/hooks/useHandleFollowChannel.tsx

@@ -0,0 +1,31 @@
+import { useFollowChannel, useUnfollowChannel } from '@/api/hooks'
+import { usePersonalDataStore } from '@/providers'
+import { SentryLogger } from '@/utils/logs'
+
+export const useHandleFollowChannel = (id?: string) => {
+  const { followChannel } = useFollowChannel()
+  const { unfollowChannel } = useUnfollowChannel()
+  const isFollowing = usePersonalDataStore((state) => state.followedChannels.some((channel) => channel.id === id))
+  const updateChannelFollowing = usePersonalDataStore((state) => state.actions.updateChannelFollowing)
+
+  const toggleFollowing = () => {
+    if (!id) {
+      return
+    }
+    try {
+      if (isFollowing) {
+        updateChannelFollowing(id, false)
+        unfollowChannel(id)
+      } else {
+        updateChannelFollowing(id, true)
+        followChannel(id)
+      }
+    } catch (error) {
+      SentryLogger.error('Failed to update Channel following', error, { channel: { id } })
+    }
+  }
+  return {
+    toggleFollowing,
+    isFollowing,
+  }
+}

+ 6 - 13
src/index.tsx

@@ -1,13 +1,11 @@
-import { CaptureConsole } from '@sentry/integrations'
-import * as Sentry from '@sentry/react'
 import React from 'react'
 import ReactDOM from 'react-dom'
 
-import { Logger } from '@/utils/logger'
+import { AssetLogger, ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { App } from './App'
 import { BUILD_ENV, TARGET_DEV_ENV } from './config/envs'
-import { SENTRY_DSN } from './config/urls'
+import { ASSET_LOGS_URL, SENTRY_DSN } from './config/urls'
 
 const initApp = async () => {
   if (BUILD_ENV !== 'production' && TARGET_DEV_ENV === 'mocking') {
@@ -15,20 +13,15 @@ const initApp = async () => {
       const { worker } = await import('./mocking/browser')
       await worker.start()
     } catch (e) {
-      Logger.error('Failed to load mocking server', e)
+      ConsoleLogger.error('Failed to load mocking server', e)
     }
   }
 
   if (BUILD_ENV === 'production') {
-    Sentry.init({
-      dsn: SENTRY_DSN,
-      integrations: [
-        new CaptureConsole({
-          levels: ['error'],
-        }),
-      ],
-    })
+    SentryLogger.initialize(SENTRY_DSN)
+    AssetLogger.initialize(ASSET_LOGS_URL)
   }
+
   ReactDOM.render(<App />, document.getElementById('root'))
 }
 

+ 8 - 22
src/joystream-lib/api.ts

@@ -33,7 +33,7 @@ import {
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import BN from 'bn.js'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import {
   AccountNotSelectedError,
@@ -87,27 +87,14 @@ export class JoystreamJs {
 
   destroy() {
     this.api.disconnect()
-    this.log('Destroyed')
-  }
-
-  /* Private utilities */
-  private log(msg: string) {
-    Logger.log(`[JoystreamJS] ${msg}`)
-  }
-
-  private logWarn(msg: string) {
-    Logger.warn(`[JoystreamJS] ${msg}`)
-  }
-
-  private logError(msg: string) {
-    Logger.error(`[JoystreamJS] ${msg}`)
+    ConsoleLogger.log('[JoystreamJs] Destroyed')
   }
 
   private async ensureApi() {
     try {
       await this.api.isReady
     } catch (e) {
-      Logger.error('Polkadot API init error', e)
+      SentryLogger.error('Failed to initialize Polkadot API', 'JoystreamJs', e)
       throw new ApiNotConnectedError()
     }
   }
@@ -115,7 +102,7 @@ export class JoystreamJs {
   private async logConnectionData(endpoint: string) {
     await this.ensureApi()
     const chain = await this.api.rpc.system.chain()
-    this.log(`Connected to chain "${chain}" via "${endpoint}"`)
+    ConsoleLogger.log(`[JoystreamJs] Connected to chain "${chain}" via "${endpoint}"`)
   }
 
   private async sendExtrinsic(
@@ -164,7 +151,6 @@ export class JoystreamJs {
                       // In this case - continue (we'll just display dispatchError.toString())
                     }
                   }
-                  this.logError(`Extrinsic failed: "${errorMsg}"`)
                   reject(new ExtrinsicFailedError(event, errorMsg))
                 } else if (event.method === 'ExtrinsicSuccess') {
                   const blockHash = status.asFinalized
@@ -173,8 +159,9 @@ export class JoystreamJs {
                     .then(({ number }) => resolve({ block: number.toNumber(), data: unpackedEvents }))
                     .catch((reason) => reject(new ExtrinsicFailedError(reason)))
                 } else {
-                  Logger.warn('Unknown event method')
-                  Logger.warn('Event:', event)
+                  SentryLogger.message('Unknown extrinsic event', 'JoystreamJs', 'warning', {
+                    event: { method: event.method },
+                  })
                 }
               })
           }
@@ -187,7 +174,6 @@ export class JoystreamJs {
           reject(new ExtrinsicSignCancelledError())
           return
         }
-        this.logError(`Unknown sendExtrinsic error: ${e}`)
         reject(e)
       }
     })
@@ -410,7 +396,7 @@ export class JoystreamJs {
       this.api.setSigner({})
       return
     } else if (!signer) {
-      this.logError('Missing signer on active account set')
+      SentryLogger.error('Missing signer for setActiveAccount', 'JoystreamJs')
       return
     }
 

+ 9 - 9
src/mocking/accessors/filtering.ts

@@ -1,6 +1,6 @@
 import { get } from 'lodash'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { FilteringArgs, GenericData, PredicateFn, SortingArgs } from '../types'
 
@@ -32,7 +32,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
 
@@ -45,7 +45,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
 
@@ -58,11 +58,11 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
     if (accessKey !== 'createdAt') {
-      Logger.warn(`skipping filtering by unsupported "_gte" field: "${key}"`)
+      ConsoleLogger.warn(`skipping filtering by unsupported "_gte" field: "${key}"`)
       return () => true
     }
 
@@ -73,7 +73,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
       return isoCreatedAt >= value
     }
   } else {
-    Logger.warn(`skipping filtering by arbitrary filter "${key}"`)
+    ConsoleLogger.warn(`skipping filtering by arbitrary filter "${key}"`)
     return () => true
   }
 }
@@ -86,7 +86,7 @@ export const genericSort = <TData extends GenericData>(data: TData[], variables:
 
   const [field, direction] = orderBy[0].split('_')
   if (!field || !direction) {
-    Logger.warn(`error parsing orderBy: "${orderBy}"`)
+    ConsoleLogger.warn(`error parsing orderBy: "${orderBy}"`)
     return data
   }
 
@@ -103,11 +103,11 @@ export const genericSort = <TData extends GenericData>(data: TData[], variables:
     } else if (direction === 'ASC') {
       return sortedData.reverse()
     } else {
-      Logger.warn(`unknown sort direction: "${direction}"`)
+      ConsoleLogger.warn(`unknown sort direction: "${direction}"`)
       return sortedData
     }
   } else {
-    Logger.warn(`unsupported sorting field: "${field}"`)
+    ConsoleLogger.warn(`unsupported sorting field: "${field}"`)
     return data
   }
 }

+ 1 - 0
src/mocking/data/mockChannels.ts

@@ -18,6 +18,7 @@ export const regularMockChannels: MockChannel[] = rawChannels.map((rawChannel, i
   isPublic: Boolean(Math.round(Math.random())),
   isCensored: Boolean(Math.round(Math.random())),
   language: {
+    id: String(Math.floor(Math.random() * 10)),
     iso: languages[Math.floor(Math.random() * languages.length)].value,
   },
 }))

+ 2 - 2
src/mocking/mutations.ts

@@ -11,7 +11,7 @@ import {
   UnfollowChannelMutation,
   UnfollowChannelMutationVariables,
 } from '@/api/queries'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { BaseDataQuery, DataMutator, Link, MocksStore } from './types'
 import { normalizeVariables, parseOperationDocument } from './utils'
@@ -23,7 +23,7 @@ const createGenericMutationHandler = <TQuery extends BaseDataQuery, TVariables =
 ) => {
   const { operationName } = parseOperationDocument(mutationDocument)
   if (!operationName) {
-    Logger.error('Unable to resolve operation name for mocking', mutationDocument)
+    ConsoleLogger.error('Unable to resolve operation name for mocking', mutationDocument)
     return
   }
 

+ 3 - 3
src/mocking/queries.ts

@@ -1,6 +1,6 @@
 import { DocumentNode } from 'graphql'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { BaseDataQuery, DataAccessor, Link } from './types'
 import { normalizeVariables, parseOperationDocument } from './utils'
@@ -13,12 +13,12 @@ export const createQueryHandler = <TQuery extends BaseDataQuery, TVariables = un
   const { operationName, primaryOperationFieldName } = parseOperationDocument(queryDocument)
 
   if (!operationName) {
-    Logger.error('Unable to resolve operation name for mocking', queryDocument)
+    ConsoleLogger.error('Unable to resolve operation name for mocking', queryDocument)
     return
   }
 
   if (!primaryOperationFieldName) {
-    Logger.error('Unable to resolve primary operation field for mocking', queryDocument)
+    ConsoleLogger.error('Unable to resolve primary operation field for mocking', queryDocument)
     return
   }
 

+ 54 - 7
src/providers/assets/assetsManager.tsx

@@ -1,7 +1,11 @@
 import { shuffle } from 'lodash'
 import React, { useEffect } from 'react'
 
-import { Logger } from '@/utils/logger'
+import { ASSET_RESPONSE_TIMEOUT } from '@/config/assets'
+import { AssetType } from '@/providers'
+import { ResolvedAssetDetails } from '@/types/assets'
+import { AssetLogger, ConsoleLogger, SentryLogger } from '@/utils/logs'
+import { TimeoutError, withTimeout } from '@/utils/misc'
 
 import { getAssetUrl, testAssetDownload } from './helpers'
 import { useAssetStore } from './store'
@@ -25,8 +29,9 @@ export const AssetsManager: React.FC = () => {
       addAssetBeingResolved(contentId)
 
       const resolutionData = pendingAssets[contentId]
-      const allStorageProviders = shuffle(getStorageProviders() || [])
-      const storageProvidersWithoutLiaison = allStorageProviders.filter(
+      const storageProviders = await getStorageProviders()
+      const shuffledStorageProviders = shuffle(storageProviders)
+      const storageProvidersWithoutLiaison = shuffledStorageProviders.filter(
         (provider) => provider.id !== resolutionData.dataObject?.liaison?.id
       )
       const liaison = resolutionData.dataObject?.liaison
@@ -35,22 +40,64 @@ export const AssetsManager: React.FC = () => {
       for (const storageProvider of storageProvidersToTry) {
         const assetUrl = getAssetUrl(resolutionData, storageProvider.metadata ?? '')
         if (!assetUrl) {
-          Logger.warn('Unable to create asset url', resolutionData)
+          ConsoleLogger.warn('Unable to create asset url', resolutionData)
           addAsset(contentId, {})
           return
         }
 
+        const assetTestPromise = testAssetDownload(assetUrl, resolutionData.assetType)
+
+        const assetDetails: ResolvedAssetDetails = {
+          contentId,
+          storageProviderId: storageProvider.workerId,
+          storageProviderUrl: storageProvider.metadata,
+          assetType: resolutionData.assetType,
+          assetUrl,
+        }
+
+        assetTestPromise.then((responseTime) => {
+          if (resolutionData.assetType === AssetType.MEDIA) {
+            // we're currently skipping monitoring video files as it's hard to measure their performance
+            // image assets are easy to measure but videos vary in length and size
+            // we will be able to handle that once we can access more detailed response timing
+            return
+          }
+
+          // if response takes <20ms assume it's coming from cache
+          // we shouldn't need that once we can do detailed timing, then we can check directly
+          if (responseTime > 20) {
+            AssetLogger.assetResponseMetric(assetDetails, responseTime)
+          }
+        })
+        assetTestPromise.catch(() => {
+          AssetLogger.assetError(assetDetails)
+          ConsoleLogger.error('Failed to load asset', assetDetails)
+        })
+        const assetTestPromiseWithTimeout = withTimeout(assetTestPromise, ASSET_RESPONSE_TIMEOUT)
+
         try {
-          await testAssetDownload(assetUrl, resolutionData.assetType)
+          await assetTestPromiseWithTimeout
           addAsset(contentId, { url: assetUrl })
           removePendingAsset(contentId)
           removeAssetBeingResolved(contentId)
           return
         } catch (e) {
-          Logger.error(`Failed to load ${resolutionData.assetType}`, { contentId, assetUrl })
+          // ignore anything else than TimeoutError as it will be handled by assetTestPromise.catch
+          if (e instanceof TimeoutError) {
+            AssetLogger.assetTimeout(assetDetails)
+            ConsoleLogger.warn('Asset load timed out', assetDetails)
+          }
         }
       }
-      Logger.error(`No storage provider was able to provide asset`, { contentId })
+
+      SentryLogger.error('No storage provider was able to provide asset', 'AssetsManager', null, {
+        asset: {
+          contentId,
+          type: resolutionData.assetType,
+          storageProviderIds: storageProvidersToTry.map((sp) => sp.workerId),
+          storageProviderUrls: storageProvidersToTry.map((sp) => sp.metadata),
+        },
+      })
     })
   }, [
     addAsset,

+ 45 - 8
src/providers/assets/helpers.ts

@@ -5,27 +5,64 @@ import {
   BasicVideoFieldsFragment,
   VideoFieldsFragment,
 } from '@/api/queries'
-import { ASSET_RESPONSE_TIMEOUT } from '@/config/assets'
 import { createStorageNodeUrl } from '@/utils/asset'
-import { withTimeout } from '@/utils/misc'
 
 import { AssetResolutionData, AssetType } from './types'
 
-export const testAssetDownload = (url: string, type: AssetType) => {
-  const testPromise = new Promise((resolve, reject) => {
+export const testAssetDownload = (url: string, type: AssetType): Promise<number> => {
+  return new Promise((_resolve, _reject) => {
+    let img: HTMLImageElement | null = null
+    let video: HTMLVideoElement | null = null
+
+    const cleanup = () => {
+      if (img) {
+        img.removeEventListener('error', reject)
+        img.removeEventListener('load', resolve)
+        img.remove()
+        img = null
+      }
+      if (video) {
+        video.removeEventListener('error', reject)
+        video.removeEventListener('loadedmetadata', resolve)
+        video.removeEventListener('loadeddata', resolve)
+        video.removeEventListener('canplay', resolve)
+        video.removeEventListener('progress', resolve)
+        video.remove()
+        video = null
+      }
+    }
+
+    const resolve = () => {
+      cleanup()
+
+      const performanceEntries = performance.getEntriesByName(url)
+      if (performanceEntries.length !== 1) {
+        _resolve(0)
+        return
+      }
+      _resolve(performanceEntries[0].duration)
+    }
+
+    const reject = () => {
+      cleanup()
+      _reject()
+    }
+
     if ([AssetType.COVER, AssetType.THUMBNAIL, AssetType.AVATAR].includes(type)) {
-      const img = new Image()
+      img = new Image()
       img.addEventListener('error', reject)
       img.addEventListener('load', resolve)
       img.src = url
     } else {
-      const video = document.createElement('video')
+      video = document.createElement('video')
       video.addEventListener('error', reject)
-      video.addEventListener('loadstart', resolve)
+      video.addEventListener('loadedmetadata', resolve)
+      video.addEventListener('loadeddata', resolve)
+      video.addEventListener('canplay', resolve)
+      video.addEventListener('progress', resolve)
       video.src = url
     }
   })
-  return withTimeout(testPromise, ASSET_RESPONSE_TIMEOUT)
 }
 export const readAssetData = (
   entity:

+ 5 - 1
src/providers/editVideoSheet/hooks.tsx

@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'
 import { useVideo } from '@/api/hooks'
 import { absoluteRoutes } from '@/config/routes'
 import { RoutingState } from '@/types/routing'
+import { SentryLogger } from '@/utils/logs'
 
 import { EditVideoSheetContext } from './provider'
 import { EditVideoAssets, EditVideoFormFields, EditVideoSheetState, EditVideoSheetTab } from './types'
@@ -25,7 +26,10 @@ export const useEditVideoSheetTabData = (tab?: EditVideoSheetTab) => {
   const { activeChannelId } = useAuthorizedUser()
   const drafts = useDraftStore(channelDraftsSelector(activeChannelId))
   const { selectedVideoTabCachedAssets } = useEditVideoSheet()
-  const { video, loading, error } = useVideo(tab?.id ?? '', { skip: tab?.isDraft })
+  const { video, loading, error } = useVideo(tab?.id ?? '', {
+    skip: tab?.isDraft,
+    onError: (error) => SentryLogger.error('Failed to fetch video', 'useEditVideoSheetTabData', error),
+  })
 
   if (!tab) {
     return {

+ 2 - 2
src/providers/joystream/provider.tsx

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react'
 
 import { NODE_URL } from '@/config/urls'
 import { JoystreamJs } from '@/joystream-lib'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useConnectionStatusStore, useUser } from '..'
 
@@ -38,7 +38,7 @@ export const JoystreamProvider: React.FC = ({ children }) => {
         joystream.onNodeConnectionUpdate = handleNodeConnectionUpdate
       } catch (e) {
         handleNodeConnectionUpdate(false)
-        Logger.error('Failed to create JoystreamJs instance', e)
+        SentryLogger.error('Failed to create JoystreamJS instance', 'JoystreamProvider', e)
       }
     }
 

+ 59 - 49
src/providers/storageProviders.tsx

@@ -1,48 +1,60 @@
-import React, { SetStateAction, useCallback, useContext, useState } from 'react'
-
-import { useStorageWorkers as useStorageProvidersData } from '@/api/hooks'
-import { BasicWorkerFieldsFragment } from '@/api/queries/__generated__/workers.generated'
-import { Logger } from '@/utils/logger'
+import { ApolloQueryResult, useApolloClient } from '@apollo/client'
+import React, { SetStateAction, useCallback, useContext, useEffect, useState } from 'react'
+import { useRef } from 'react'
+
+import { storageWorkersVariables } from '@/api/hooks'
+import {
+  BasicWorkerFieldsFragment,
+  GetWorkersDocument,
+  GetWorkersQuery,
+  GetWorkersQueryVariables,
+} from '@/api/queries/__generated__/workers.generated'
+import { ViewErrorFallback } from '@/components'
+import { SentryLogger } from '@/utils/logs'
 import { getRandomIntInclusive } from '@/utils/number'
 
+type StorageProvidersPromise = Promise<ApolloQueryResult<GetWorkersQuery>>
+
 type StorageProvidersContextValue = {
-  storageProviders: BasicWorkerFieldsFragment[]
-  storageProvidersLoading: boolean
+  storageProvidersPromiseRef: React.MutableRefObject<StorageProvidersPromise | undefined>
   notWorkingStorageProvidersIds: string[]
   setNotWorkingStorageProvidersIds: React.Dispatch<SetStateAction<string[]>>
 }
 const StorageProvidersContext = React.createContext<StorageProvidersContextValue | undefined>(undefined)
 StorageProvidersContext.displayName = 'StorageProvidersContext'
 
-class NoStorageProviderError extends Error {
-  storageProviders: string[]
-  notWorkingStorageProviders: string[]
-
-  constructor(message: string, storageProviders: string[], notWorkingStorageProviders: string[]) {
-    super(message)
-
-    this.storageProviders = storageProviders
-    this.notWorkingStorageProviders = notWorkingStorageProviders
-  }
-}
-
 // ¯\_(ツ)_/¯ for the name
 export const StorageProvidersProvider: React.FC = ({ children }) => {
   const [notWorkingStorageProvidersIds, setNotWorkingStorageProvidersIds] = useState<string[]>([])
+  const [storageProvidersError, setStorageProvidersError] = useState<unknown>(null)
+  const storageProvidersPromiseRef = useRef<StorageProvidersPromise>()
 
-  const { storageProviders, loading } = useStorageProvidersData(
-    { limit: 100 },
-    {
+  const client = useApolloClient()
+
+  useEffect(() => {
+    const promise = client.query<GetWorkersQuery, GetWorkersQueryVariables>({
+      query: GetWorkersDocument,
       fetchPolicy: 'network-only',
-      onError: (error) => Logger.error('Failed to fetch storage providers list', error),
-    }
-  )
+      variables: {
+        ...storageWorkersVariables,
+        limit: 100,
+      },
+    })
+    storageProvidersPromiseRef.current = promise
+    promise.catch((error) => {
+      SentryLogger.error('Failed to fetch storage providers list', 'StorageProvidersProvider', error)
+      setStorageProvidersError(error)
+    })
+  }, [client])
+
+  if (storageProvidersError) {
+    return <ViewErrorFallback />
+  }
 
   return (
     <StorageProvidersContext.Provider
       value={{
-        storageProvidersLoading: loading,
-        storageProviders: storageProviders || [],
+        storageProvidersPromiseRef: storageProvidersPromiseRef,
         notWorkingStorageProvidersIds,
         setNotWorkingStorageProvidersIds,
       }}
@@ -59,19 +71,16 @@ export const useStorageProviders = () => {
     throw new Error('useStorageProviders must be used within StorageProvidersProvider')
   }
 
-  const {
-    storageProvidersLoading,
-    storageProviders,
-    notWorkingStorageProvidersIds,
-    setNotWorkingStorageProvidersIds,
-  } = ctx
-
-  const getStorageProviders = useCallback(() => {
-    // make sure we finished fetching providers list
-    if (storageProvidersLoading) {
-      // TODO: we need to handle that somehow, possibly make it async and block until ready
-      Logger.error('Trying to use storage providers while still loading')
-      return null
+  const { storageProvidersPromiseRef, notWorkingStorageProvidersIds, setNotWorkingStorageProvidersIds } = ctx
+
+  const getStorageProviders = useCallback(async () => {
+    let storageProviders: BasicWorkerFieldsFragment[] = []
+    try {
+      const storageProvidersData = await storageProvidersPromiseRef.current
+      storageProviders = storageProvidersData?.data.workers || []
+    } catch {
+      // error is handled by the context
+      return []
     }
 
     const workingStorageProviders = storageProviders.filter(
@@ -79,19 +88,20 @@ export const useStorageProviders = () => {
     )
 
     if (!workingStorageProviders.length) {
-      throw new NoStorageProviderError(
-        'No storage provider available',
-        storageProviders.map(({ workerId }) => workerId),
-        notWorkingStorageProvidersIds
-      )
+      SentryLogger.error('No storage provider available', 'StorageProvidersProvider', null, {
+        providers: {
+          allIds: storageProviders.map(({ workerId }) => workerId),
+          notWorkingIds: notWorkingStorageProvidersIds,
+        },
+      })
     }
 
     return workingStorageProviders
-  }, [notWorkingStorageProvidersIds, storageProviders, storageProvidersLoading])
+  }, [notWorkingStorageProvidersIds, storageProvidersPromiseRef])
 
-  const getRandomStorageProvider = useCallback(() => {
-    const workingStorageProviders = getStorageProviders()
-    if (!workingStorageProviders) {
+  const getRandomStorageProvider = useCallback(async () => {
+    const workingStorageProviders = await getStorageProviders()
+    if (!workingStorageProviders || !workingStorageProviders.length) {
       return null
     }
     const randomStorageProviderIdx = getRandomIntInclusive(0, workingStorageProviders.length - 1)

+ 2 - 2
src/providers/transactionManager/transactionManager.tsx

@@ -2,7 +2,7 @@ import React from 'react'
 
 import { useQueryNodeStateSubscription } from '@/api/hooks'
 import { TransactionDialog } from '@/components'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useTransactionManagerStore } from './store'
 
@@ -24,7 +24,7 @@ export const TransactionManager: React.FC = () => {
         try {
           action.callback()
         } catch (e) {
-          Logger.error('Failed to execute tx sync callback', e)
+          SentryLogger.error('Failed to execute tx sync callback', 'TransactionManager', e)
         }
       })
 

+ 16 - 9
src/providers/transactionManager/useTransaction.ts

@@ -1,6 +1,6 @@
-import { ExtrinsicResult, ExtrinsicSignCancelledError, ExtrinsicStatus } from '@/joystream-lib'
+import { ExtrinsicFailedError, ExtrinsicResult, ExtrinsicSignCancelledError, ExtrinsicStatus } from '@/joystream-lib'
 import { TransactionDialogStep, useConnectionStatusStore, useDialog, useSnackbar } from '@/providers'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { useTransactionManagerStore } from './store'
 
@@ -56,7 +56,7 @@ export const useTransaction = (): HandleTransactionFn => {
         try {
           await preProcess()
         } catch (e) {
-          Logger.error('Failed transaction preprocess', e)
+          SentryLogger.error('Failed transaction preprocess', 'TransactionManager', e)
           return false
         }
       }
@@ -65,7 +65,9 @@ export const useTransaction = (): HandleTransactionFn => {
       setDialogStep(ExtrinsicStatus.Unsigned)
       const { data: txData, block } = await txFactory(setDialogStep)
       if (onTxFinalize) {
-        onTxFinalize(txData).catch((e) => Logger.error('Failed transaction finalize callback', e))
+        onTxFinalize(txData).catch((e) =>
+          SentryLogger.error('Failed transaction finalize callback', 'TransactionManager', e)
+        )
       }
 
       setDialogStep(ExtrinsicStatus.Syncing)
@@ -75,7 +77,7 @@ export const useTransaction = (): HandleTransactionFn => {
             try {
               await onTxSync(txData)
             } catch (e) {
-              Logger.error('Failed transaction sync callback', e)
+              SentryLogger.error('Failed transaction sync callback', 'TransactionManager', e)
             }
           }
           resolve()
@@ -105,18 +107,23 @@ export const useTransaction = (): HandleTransactionFn => {
       })
     } catch (e) {
       if (e instanceof ExtrinsicSignCancelledError) {
-        Logger.warn('Sign cancelled')
+        ConsoleLogger.warn('Sign cancelled')
         setDialogStep(null)
         displaySnackbar({
           title: 'Transaction signing cancelled',
           iconType: 'warning',
           timeout: TX_SIGN_CANCELLED_SNACKBAR_TIMEOUT,
         })
+        return false
+      }
+
+      if (e instanceof ExtrinsicFailedError) {
+        SentryLogger.error('Extrinsic failed', 'TransactionManager', e)
       } else {
-        Logger.error(e)
-        setDialogStep(null)
-        openErrorDialog()
+        SentryLogger.error('Unknown sendExtrinsic error', 'TransactionManager', e)
       }
+      setDialogStep(null)
+      openErrorDialog()
       return false
     }
   }

+ 13 - 6
src/providers/uploadsManager/useStartFileUpload.tsx

@@ -6,7 +6,7 @@ import * as rax from 'retry-axios'
 
 import { absoluteRoutes } from '@/config/routes'
 import { createStorageNodeUrl } from '@/utils/asset'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { useUploadsStore } from './store'
 import { InputAssetUpload, StartFileUploadOptions, UploadStatus } from './types'
@@ -82,18 +82,23 @@ export const useStartFileUpload = () => {
     async (file: File | Blob | null, asset: InputAssetUpload, opts?: StartFileUploadOptions) => {
       let storageUrl: string, storageProviderId: string
       try {
-        const storageProvider = getRandomStorageProvider()
+        const storageProvider = await getRandomStorageProvider()
         if (!storageProvider) {
+          SentryLogger.error('No storage provider available for upload', 'UploadsManager')
           return
         }
         storageUrl = storageProvider.url
         storageProviderId = storageProvider.id
       } catch (e) {
-        Logger.error('Failed to find storage provider', e)
+        SentryLogger.error('Failed to get storage provider for upload', 'UploadsManager', e)
         return
       }
 
-      Logger.debug(`Uploading to ${storageUrl}`)
+      ConsoleLogger.debug('Starting file upload', {
+        contentId: asset.contentId,
+        storageProviderId,
+        storageProviderUrl: storageUrl,
+      })
 
       const setAssetStatus = (status: Partial<UploadStatus>) => {
         setUploadStatus(asset.contentId, status)
@@ -104,13 +109,13 @@ export const useStartFileUpload = () => {
       }
 
       const assetKey = `${asset.parentObject.type}-${asset.parentObject.id}`
+      const assetUrl = createStorageNodeUrl(asset.contentId, storageUrl)
 
       try {
         if (!fileInState && !file) {
           throw Error('File was not provided nor found')
         }
         rax.attach()
-        const assetUrl = createStorageNodeUrl(asset.contentId, storageUrl)
         if (!opts?.isReUpload && !opts?.changeHost && file) {
           addAsset({ ...asset, size: file.size })
         }
@@ -162,7 +167,9 @@ export const useStartFileUpload = () => {
           (assetsNotificationsCount.current.uploaded[assetKey] || 0) + 1
         displayUploadedNotification.current(assetKey)
       } catch (e) {
-        Logger.error('Failed to upload to storage provider', { storageUrl, error: e })
+        SentryLogger.error('Failed to upload asset', 'UploadsManager', e, {
+          asset: { contentId: asset.contentId, storageProviderId, storageProviderUrl: storageUrl, assetUrl },
+        })
         setAssetStatus({ lastStatus: 'error', progress: 0 })
 
         const axiosError = e as AxiosError

+ 38 - 27
src/providers/user/store.ts

@@ -1,6 +1,6 @@
-import { useCallback, useEffect, useState } from 'react'
-
 import { AccountId, ChannelId, MemberId } from '@/joystream-lib'
+import { createStore } from '@/store'
+import { readFromLocalStorage } from '@/utils/localStorage'
 
 const LOCAL_STORAGE_KEY = 'activeUser'
 
@@ -16,30 +16,41 @@ const EMPTY_STATE: ActiveUserState = {
   channelId: null,
 }
 
-const getInitialState = (): ActiveUserState => {
-  const rawData = localStorage.getItem(LOCAL_STORAGE_KEY)
-  return rawData ? JSON.parse(rawData) : EMPTY_STATE
-}
-
-export const useActiveUserStore = () => {
-  const [state, setState] = useState<ActiveUserState>(getInitialState())
-
-  // synchronize state with local storage on change
-  useEffect(() => {
-    const rawData = JSON.stringify(state)
-    localStorage.setItem(LOCAL_STORAGE_KEY, rawData)
-  }, [state])
+const WHITELIST = ['accountId', 'memberId', 'channelId'] as (keyof ActiveUserState)[]
 
-  const setActiveUser = useCallback((changes: Partial<ActiveUserState>) => {
-    setState((currentState) => ({
-      ...currentState,
-      ...changes,
-    }))
-  }, [])
-
-  const resetActiveUser = useCallback(() => {
-    setState(EMPTY_STATE)
-  }, [])
-
-  return { activeUserState: state, setActiveUser, resetActiveUser }
+export type ActiveUserStoreActions = {
+  resetActiveUser: () => void
+  setActiveUser: (activeUserChanges: Partial<ActiveUserState>) => void
 }
+
+export const useActiveUserStore = createStore<ActiveUserState, ActiveUserStoreActions>(
+  {
+    state: EMPTY_STATE,
+    actionsFactory: (set) => ({
+      resetActiveUser: () => {
+        set((state) => ({ ...state, ...EMPTY_STATE }))
+      },
+      setActiveUser: (activeUserChanges) => {
+        set((state) => {
+          state.accountId = activeUserChanges.accountId || state.accountId
+          state.memberId = activeUserChanges.memberId || state.memberId
+          state.channelId = activeUserChanges.channelId || state.channelId
+        })
+      },
+    }),
+  },
+  {
+    persist: {
+      key: LOCAL_STORAGE_KEY,
+      version: 0,
+      whitelist: WHITELIST,
+      migrate: (oldState, oldVersion) => {
+        // migrate store before zustand was added
+        if (oldVersion === undefined) {
+          const activeUser = readFromLocalStorage<ActiveUserState>(LOCAL_STORAGE_KEY)
+          return activeUser
+        }
+      },
+    },
+  }
+)

+ 32 - 17
src/providers/user/user.tsx

@@ -3,18 +3,20 @@ import { InjectedAccountWithMeta } from '@polkadot/extension-inject/types'
 import React, { useContext, useEffect, useState } from 'react'
 
 import { useMembership, useMemberships } from '@/api/hooks'
+import { ViewErrorFallback } from '@/components'
 import { WEB3_APP_NAME } from '@/config/urls'
 import { AccountId } from '@/joystream-lib'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
-import { useActiveUserStore } from './store'
+import { ActiveUserState, ActiveUserStoreActions, useActiveUserStore } from './store'
 
 export type Account = {
   id: AccountId
   name: string
 }
 
-type ActiveUserContextValue = ReturnType<typeof useActiveUserStore> & {
+type ActiveUserContextValue = ActiveUserStoreActions & {
+  activeUserState: ActiveUserState
   accounts: Account[] | null
   extensionConnected: boolean | null
 
@@ -28,11 +30,13 @@ type ActiveUserContextValue = ReturnType<typeof useActiveUserStore> & {
 
   userInitialized: boolean
 }
+
 const ActiveUserContext = React.createContext<undefined | ActiveUserContextValue>(undefined)
 ActiveUserContext.displayName = 'ActiveUserContext'
 
 export const ActiveUserProvider: React.FC = ({ children }) => {
-  const { activeUserState, setActiveUser, resetActiveUser } = useActiveUserStore()
+  const activeUserState = useActiveUserStore(({ actions, ...activeUser }) => ({ ...activeUser }))
+  const { setActiveUser, resetActiveUser } = useActiveUserStore((state) => state.actions)
 
   const [accounts, setAccounts] = useState<Account[] | null>(null)
   const [extensionConnected, setExtensionConnected] = useState<boolean | null>(null)
@@ -44,7 +48,16 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     loading: membershipsLoading,
     error: membershipsError,
     refetch: refetchMemberships,
-  } = useMemberships({ where: { controllerAccount_in: accountsIds } }, { skip: !accounts || !accounts.length })
+  } = useMemberships(
+    { where: { controllerAccount_in: accountsIds } },
+    {
+      skip: !accounts || !accounts.length,
+      onError: (error) =>
+        SentryLogger.error('Failed to fetch memberships', 'ActiveUserProvider', error, {
+          accounts: { ids: accountsIds },
+        }),
+    }
+  )
 
   // use previous values when doing the refetch, so the app doesn't think we don't have any memberships
   const memberships = membershipsData || membershipPreviousData?.memberships
@@ -54,15 +67,13 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     loading: activeMembershipLoading,
     error: activeMembershipError,
     refetch: refetchActiveMembership,
-  } = useMembership({ where: { id: activeUserState.memberId } }, { skip: !activeUserState.memberId })
-
-  if (membershipsError) {
-    throw membershipsError
-  }
-
-  if (activeMembershipError) {
-    throw activeMembershipError
-  }
+  } = useMembership(
+    { where: { id: activeUserState.memberId } },
+    {
+      skip: !activeUserState.memberId,
+      onError: (error) => SentryLogger.error('Failed to fetch active membership', 'ActiveUserProvider', error),
+    }
+  )
 
   // handle polkadot extension
   useEffect(() => {
@@ -73,7 +84,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
         const enabledExtensions = await web3Enable(WEB3_APP_NAME)
 
         if (!enabledExtensions.length) {
-          Logger.warn('No Polkadot extension detected')
+          ConsoleLogger.warn('No Polkadot extension detected')
           setExtensionConnected(false)
           return
         }
@@ -94,7 +105,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
         setExtensionConnected(true)
       } catch (e) {
         setExtensionConnected(false)
-        Logger.error('Unknown polkadot extension error', e)
+        SentryLogger.error('Failed to initialize Polkadot signer extension', 'ActiveUserProvider', e)
       }
     }
 
@@ -113,7 +124,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     const account = accounts.find((a) => a.id === activeUserState.accountId)
 
     if (!account) {
-      Logger.warn('Selected accountId not found in extension accounts, resetting user')
+      ConsoleLogger.warn('Selected accountId not found in extension accounts, resetting user')
       resetActiveUser()
     }
   }, [accounts, activeUserState.accountId, extensionConnected, resetActiveUser])
@@ -140,6 +151,10 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     userInitialized,
   }
 
+  if (membershipsError || activeMembershipError) {
+    return <ViewErrorFallback />
+  }
+
   return <ActiveUserContext.Provider value={contextValue}>{children}</ActiveUserContext.Provider>
 }
 

+ 17 - 19
src/shared/components/ActionBar/ActionBarTransaction.tsx

@@ -10,22 +10,20 @@ export type ActionBarTransactionProps = {
   progressDrawerSteps?: Step[]
 } & Omit<ActionBarProps, 'primaryText' | 'secondaryText'>
 
-export const ActionBarTransaction: React.FC<ActionBarTransactionProps> = ({
-  fee,
-  fullWidth,
-  isActive,
-  progressDrawerSteps,
-  ...actionBarArgs
-}) => {
-  return (
-    <ActionBarTransactionWrapper fullWidth={fullWidth} isActive={isActive}>
-      {progressDrawerSteps?.length ? <StyledProgressDrawer steps={progressDrawerSteps} /> : null}
-      <ActionBar
-        {...actionBarArgs}
-        fullWidth={fullWidth}
-        primaryText={`Fee: ${fee} Joy`}
-        secondaryText="For the time being no fees are required for blockchain transactions. This will change in the future."
-      />
-    </ActionBarTransactionWrapper>
-  )
-}
+export const ActionBarTransaction = React.forwardRef<HTMLDivElement, ActionBarTransactionProps>(
+  ({ fee, fullWidth, isActive, progressDrawerSteps, ...actionBarArgs }, ref) => {
+    return (
+      <ActionBarTransactionWrapper ref={ref} fullWidth={fullWidth} isActive={isActive}>
+        {progressDrawerSteps?.length ? <StyledProgressDrawer steps={progressDrawerSteps} /> : null}
+        <ActionBar
+          {...actionBarArgs}
+          fullWidth={fullWidth}
+          primaryText={`Fee: ${fee} Joy`}
+          secondaryText="For the time being no fees are required for blockchain transactions. This will change in the future."
+        />
+      </ActionBarTransactionWrapper>
+    )
+  }
+)
+
+ActionBarTransaction.displayName = 'ActionBarTransaction'

+ 13 - 1
src/shared/components/Avatar/Avatar.style.tsx

@@ -7,7 +7,7 @@ import { colors, media, transitions, typography } from '@/shared/theme'
 
 import { SkeletonLoader } from '../SkeletonLoader'
 
-export type AvatarSize = 'preview' | 'cover' | 'view' | 'default' | 'fill' | 'small' | 'channel'
+export type AvatarSize = 'preview' | 'cover' | 'view' | 'default' | 'fill' | 'small' | 'channel' | 'channel-card'
 
 type ContainerProps = {
   size: AvatarSize
@@ -45,6 +45,16 @@ const channelAvatarCss = css`
     height: 136px;
   }
 `
+const channelCardAvatarCss = css`
+  width: 88px;
+  min-width: 88px;
+  height: 88px;
+  ${media.medium} {
+    width: 104px;
+    min-width: 104px;
+    height: 104px;
+  }
+`
 
 const viewAvatarCss = css`
   width: 128px;
@@ -84,6 +94,8 @@ const getAvatarSizeCss = (size: AvatarSize): SerializedStyles => {
       return viewAvatarCss
     case 'channel':
       return channelAvatarCss
+    case 'channel-card':
+      return channelCardAvatarCss
     case 'fill':
       return fillAvatarCss
     case 'small':

+ 6 - 0
src/shared/components/Button/Button.stories.tsx

@@ -14,6 +14,12 @@ export default {
     className: { table: { disable: true } },
     to: { table: { disable: true } },
     type: { table: { disable: true } },
+    variant: { table: { disable: true } },
+    fullWidth: {
+      table: { disable: false, type: { summary: 'boolean' }, defaultValue: { summary: false } },
+      type: { name: 'boolean', required: false },
+      defaultValue: false,
+    },
     textOnly: { table: { disable: true } },
     iconPlacement: {
       control: { type: 'select', options: ['left', 'right'] },

+ 1 - 1
src/shared/components/Button/Button.tsx

@@ -8,7 +8,7 @@ import { TextVariant } from '../Text'
 export type ButtonProps = {
   icon?: React.ReactNode
   iconPlacement?: IconPlacement
-  children: string
+  children: React.ReactNode
 } & Omit<ButtonBaseProps, 'children'>
 
 const BUTTON_SIZE_TO_TEXT_VARIANT: Record<ButtonSize, TextVariant> = {

+ 2 - 0
src/shared/components/ButtonBase/ButtonBase.style.ts

@@ -17,6 +17,7 @@ export type ButtonBaseStyleProps = {
   variant: ButtonVariant
   size: ButtonSize
   clickable?: boolean
+  fullWidth?: boolean
   textOnly: boolean
   iconOnly: boolean
 }
@@ -227,6 +228,7 @@ export const StyledButtonBase = styled('button', { shouldForwardProp: isPropVali
   border: 0;
   background-color: transparent;
   cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')};
+  width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
 
   &:disabled,
   &[aria-disabled='true'] {

+ 3 - 14
src/shared/components/ButtonBase/ButtonBase.tsx

@@ -1,6 +1,7 @@
 import { To } from 'history'
 import React from 'react'
-import { Link } from 'react-router-dom'
+
+import { getLinkPropsFromTo } from '@/utils/button'
 
 import { BorderWrapper, ButtonBaseStyleProps, StyledButtonBase } from './ButtonBase.style'
 
@@ -13,19 +14,7 @@ export type ButtonBaseProps = {
   iconOnly?: boolean
   children?: React.ReactNode
   className?: string
-} & Partial<Pick<ButtonBaseStyleProps, 'size' | 'variant'>>
-
-const getLinkPropsFromTo = (to?: To) => {
-  if (!to) {
-    return {}
-  }
-
-  if (typeof to === 'string' && to.includes('http')) {
-    return { as: 'a', href: to, rel: 'noopener noreferrer', target: '_blank' } as const
-  }
-
-  return { as: Link, to: to }
-}
+} & Partial<Pick<ButtonBaseStyleProps, 'size' | 'variant' | 'fullWidth'>>
 
 export const ButtonBase = React.forwardRef<HTMLButtonElement, ButtonBaseProps>(
   (

+ 30 - 0
src/shared/components/CallToActionButton/CallToActionButton.stories.tsx

@@ -0,0 +1,30 @@
+import { Meta, Story } from '@storybook/react'
+import React from 'react'
+
+import { SvgNavPopular } from '@/shared/icons'
+
+import { CallToActionButton, CallToActionButtonProps, CallToActionWrapper } from '.'
+
+export default {
+  title: 'Shared/C/CallToActionButton',
+  component: CallToActionButton,
+  argTypes: {
+    colorVariant: {
+      control: { type: 'select', options: ['blue', 'green', 'red', 'yellow'] },
+      defaultValue: 'blue',
+    },
+  },
+} as Meta
+
+const Template: Story<CallToActionButtonProps> = (args) => {
+  return (
+    <CallToActionWrapper>
+      <CallToActionButton {...args} />
+    </CallToActionWrapper>
+  )
+}
+export const Default = Template.bind({})
+Default.args = {
+  label: 'Call To Action Button',
+  icon: <SvgNavPopular />,
+}

+ 79 - 0
src/shared/components/CallToActionButton/CallToActionButton.style.ts

@@ -0,0 +1,79 @@
+import isPropValid from '@emotion/is-prop-valid'
+import styled from '@emotion/styled'
+
+import { colors, media, sizes, transitions } from '@/shared/theme'
+
+import { ColorVariants } from './CallToActionButton'
+
+import { CallToActionButtonProps } from '.'
+import { Text } from '../Text'
+
+const mappedColors = {
+  blue: colors.blue[500],
+  lightBlue: colors.blue[200],
+  red: colors.secondary.alert[100],
+  yellow: colors.secondary.warning[100],
+  green: colors.secondary.success[100],
+}
+
+export const CallToActionWrapper = styled.div`
+  margin-top: ${sizes(32)};
+
+  ${media.medium} {
+    display: grid;
+    grid-template-columns: 1fr 1fr 1fr;
+    grid-column-gap: ${sizes(6)};
+  }
+`
+type IconWrapperProps = {
+  colorVariant: ColorVariants
+}
+
+export const IconWrapper = styled.div<IconWrapperProps>`
+  margin-bottom: ${sizes(5)};
+
+  path {
+    fill: ${({ colorVariant = 'blue' }) => mappedColors[colorVariant]};
+  }
+`
+
+export const BodyWrapper = styled(Text)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`
+
+export const ContentWrapper = styled.div`
+  padding: ${sizes(8)};
+  background-color: ${colors.gray[800]};
+  transition: all ${transitions.timings.regular} ${transitions.easing};
+`
+type StyledContainerProps = Omit<CallToActionButtonProps, 'label'>
+
+export const StyledContainer = styled('button', { shouldForwardProp: isPropValid })<StyledContainerProps>`
+  display: block;
+  width: 100%;
+  align-items: center;
+  cursor: pointer;
+  border: 0;
+  color: ${colors.white};
+  text-decoration: none;
+  background-color: transparent;
+
+  &:not(:last-child) {
+    margin-bottom: ${sizes(4)};
+  }
+
+  ${media.medium} {
+    &:not(:last-child) {
+      margin-bottom: 0;
+    }
+  }
+
+  :hover {
+    ${ContentWrapper} {
+      transform: translate(-${sizes(2)}, -${sizes(2)});
+      box-shadow: ${({ colorVariant = 'blue' }) => `${sizes(2)} ${sizes(2)} 0 ${mappedColors[colorVariant]}`};
+    }
+  }
+`

+ 39 - 0
src/shared/components/CallToActionButton/CallToActionButton.tsx

@@ -0,0 +1,39 @@
+import { To } from 'history'
+import React, { FC, MouseEvent, ReactNode } from 'react'
+
+import { SvgGlyphChevronRight } from '@/shared/icons'
+import { getLinkPropsFromTo } from '@/utils/button'
+
+import { BodyWrapper, ContentWrapper, IconWrapper, StyledContainer } from './CallToActionButton.style'
+
+export type ColorVariants = 'red' | 'green' | 'yellow' | 'blue' | 'lightBlue'
+
+export type CallToActionButtonProps = {
+  to?: To
+  onClick?: (event: MouseEvent<HTMLButtonElement>) => void
+  icon?: ReactNode
+  colorVariant?: ColorVariants
+  label: string
+}
+
+export const CallToActionButton: FC<CallToActionButtonProps> = ({
+  to,
+  icon,
+  onClick,
+  colorVariant = 'blue',
+  label,
+}) => {
+  const linkProps = getLinkPropsFromTo(to)
+
+  return (
+    <StyledContainer {...linkProps} onClick={onClick} colorVariant={colorVariant}>
+      <ContentWrapper>
+        <IconWrapper colorVariant={colorVariant === 'blue' ? 'lightBlue' : colorVariant}>{icon}</IconWrapper>
+        <BodyWrapper variant="h5">
+          {label}
+          <SvgGlyphChevronRight />
+        </BodyWrapper>
+      </ContentWrapper>
+    </StyledContainer>
+  )
+}

+ 3 - 0
src/shared/components/CallToActionButton/index.ts

@@ -0,0 +1,3 @@
+export * from './CallToActionButton'
+export { CallToActionWrapper } from './CallToActionButton.style'
+export type { CallToActionButtonProps } from './CallToActionButton'

+ 4 - 2
src/shared/components/Carousel/Carousel.stories.tsx

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled'
 import { Meta, Story } from '@storybook/react'
-import React from 'react'
+import React, { useRef } from 'react'
 
 import { colors } from '@/shared/theme'
 
@@ -12,8 +12,10 @@ export default {
 } as Meta
 
 const Template: Story<CarouselProps> = (args) => {
+  const prevArrowRef = useRef<HTMLButtonElement>(null)
+  const nextArrowRef = useRef<HTMLButtonElement>(null)
   return (
-    <Carousel {...args}>
+    <Carousel {...args} prevArrowRef={prevArrowRef} nextArrowRef={nextArrowRef} dotsVisible>
       {Array.from({ length: 10 }, (_, i) => (
         <CarouselItem key={i}> Carousel Item {i}</CarouselItem>
       ))}

+ 67 - 48
src/shared/components/Carousel/Carousel.style.ts

@@ -1,6 +1,7 @@
 import styled from '@emotion/styled'
 
-import { zIndex } from '../../theme'
+import { colors, media, sizes, transitions, typography, zIndex } from '@/shared/theme'
+
 import { IconButton } from '../IconButton'
 
 export const CAROUSEL_ARROW_HEIGHT = 48
@@ -9,45 +10,26 @@ export const Container = styled.div`
   position: relative;
 `
 
-type HasDirection = {
-  direction: 'prev' | 'next'
-}
-
-type HasPadding = {
-  paddingLeft: number
-  paddingTop: number
-}
-
-type ArrowProps = {
-  arrowPosition?: number
-}
-
-export const BackgroundGradient = styled.div<HasDirection & HasPadding>`
-  position: absolute;
-  top: 0;
-  left: ${(props) => (props.direction === 'prev' ? 0 : 'auto')};
-  right: ${(props) => (props.direction === 'next' ? 0 : 'auto')};
-  bottom: 0;
-  margin-left: ${(props) => -props.paddingLeft}px;
-  margin-top: ${(props) => -props.paddingTop}px;
-  width: 10%;
-  z-index: ${zIndex.overlay};
-  background-image: linear-gradient(
-    ${(props) => (props.direction === 'prev' ? 270 : 90)}deg,
-    transparent,
-    var(--gradientColor, transparent)
-  );
-  pointer-events: none;
-`
-
-export const Arrow = styled(IconButton)<ArrowProps>`
-  position: absolute;
-  top: ${({ arrowPosition }) => arrowPosition && `${arrowPosition}px`};
+export const Arrow = styled(IconButton)`
+  display: none;
   z-index: ${zIndex.nearOverlay};
   cursor: pointer;
+  padding: ${sizes(2)};
+  font-size: ${typography.sizes.subtitle2};
+
+  ${media.medium} {
+    display: block;
+  }
 
   &.disabled {
-    display: none;
+    opacity: 0.5;
+  }
+
+  &.glider-prev,
+  &.glider-next {
+    position: relative;
+    top: 0;
+    padding: ${sizes(2)};
   }
 
   &.glider-prev {
@@ -57,21 +39,58 @@ export const Arrow = styled(IconButton)<ArrowProps>`
   &.glider-next {
     right: 0;
   }
-  + ${BackgroundGradient} {
-    --gradientColor: black;
-  }
-  &.disabled + ${BackgroundGradient} {
-    --gradientColor: transparent;
-  }
 `
 
-export const GliderContainer = styled.div<HasPadding>`
-  padding-left: ${(props) => props.paddingLeft}px;
-  padding-top: ${(props) => props.paddingTop}px;
-  margin-left: ${(props) => -props.paddingLeft}px;
-  margin-top: ${(props) => -props.paddingTop}px;
+export const GliderContainer = styled.div`
+  padding-left: ${sizes(2)};
+  padding-top: ${sizes(2)};
 `
 
 export const Track = styled.div`
-  align-items: flex-start;
+  .glider-slide:not(:first-of-type) {
+    margin-left: ${sizes(4)};
+    ${media.large} {
+      margin-left: ${sizes(6)};
+    }
+  }
+`
+
+export const Dots = styled.div`
+  padding: ${sizes(5.5)} 0;
+  margin-top: ${sizes(12)};
+  display: none;
+
+  ${media.medium} {
+    display: flex;
+  }
+
+  .glider-dot {
+    background-color: transparent;
+    width: ${sizes(10)};
+    border-radius: 0;
+    padding: ${sizes(1)};
+    margin: 0;
+
+    &::after {
+      content: '';
+      width: 100%;
+      height: ${sizes(1)};
+      display: block;
+      background-color: ${colors.gray[700]};
+      transition: all ${transitions.timings.regular} ${transitions.easing};
+    }
+
+    &:hover:not(.active) {
+      &::after {
+        background-color: ${colors.gray[50]};
+        transform: translateY(-2px);
+      }
+    }
+
+    &.active {
+      &::after {
+        background-color: ${colors.gray[300]};
+      }
+    }
+  }
 `

+ 102 - 42
src/shared/components/Carousel/Carousel.tsx

@@ -1,51 +1,111 @@
-import React, { useRef } from 'react'
+import Glider from 'glider-js'
+import React, {
+  ComponentPropsWithoutRef,
+  ReactNode,
+  RefObject,
+  forwardRef,
+  useEffect,
+  useImperativeHandle,
+  useRef,
+} from 'react'
 
-import { SvgGlyphChevronLeft, SvgGlyphChevronRight } from '@/shared/icons'
-
-import { Arrow, BackgroundGradient, Container, GliderContainer, Track } from './Carousel.style'
+import { Container, Dots, GliderContainer, Track } from './Carousel.style'
 
 import { GliderProps, useGlider } from '../Glider'
 
 export type CarouselProps = {
-  paddingLeft?: number
-  paddingTop?: number
   className?: string
   arrowPosition?: number
+  dotsVisible?: boolean
 } & GliderProps
+export const Carousel = forwardRef<
+  ReactNode,
+  CarouselProps &
+    ComponentPropsWithoutRef<'div'> & {
+      prevArrowRef: RefObject<HTMLButtonElement>
+      nextArrowRef: RefObject<HTMLButtonElement>
+    }
+>(
+  (
+    {
+      children,
+      className = '',
+      arrowPosition,
+      slidesToShow = 'auto',
+      dotsVisible,
+      prevArrowRef,
+      nextArrowRef,
+      ...gliderOptions
+    },
+    ref
+  ) => {
+    const dotsRef = useRef<HTMLDivElement>(null)
+    const gliderInstanceRef = useRef<Glider.Static<HTMLElement>>()
+    const slidesToScrollRef = useRef<number>(0)
+
+    const onAnimated = () => {
+      if (gliderInstanceRef.current && gliderOptions.responsive) {
+        const breakpointIndex = gliderOptions.responsive.findIndex(
+          (item) => item.breakpoint === gliderInstanceRef.current?.breakpoint
+        )
+        const slidesToScroll = gliderOptions.responsive[breakpointIndex].settings.slidesToScroll as number
+        const itemsRemainder = gliderInstanceRef.current.slides.length % slidesToScrollRef.current || slidesToScroll
+        if (nextArrowRef.current && nextArrowRef.current?.classList.contains('disabled') && itemsRemainder) {
+          gliderInstanceRef.current.setOption({ slidesToScroll: itemsRemainder }, false)
+        } else {
+          gliderInstanceRef.current.setOption({ slidesToScroll: slidesToScrollRef.current || slidesToScroll }, false)
+          if (!slidesToScrollRef.current) {
+            slidesToScrollRef.current = slidesToScroll
+          }
+        }
+      }
+    }
+
+    const {
+      ref: gliderRef,
+      getContainerProps,
+      getGliderProps,
+      getTrackProps,
+      getPrevArrowProps,
+      getNextArrowProps,
+      getDotsProps,
+      glider,
+    } = useGlider<HTMLDivElement>({
+      slidesToShow,
+      onAnimated,
+      arrows: { prev: prevArrowRef.current, next: nextArrowRef.current },
+      dots: dotsRef.current,
+      ...gliderOptions,
+    })
+
+    const resetSlidesToScroll = () => {
+      slidesToScrollRef.current = 0
+    }
+
+    useEffect(() => {
+      window.addEventListener('resize', resetSlidesToScroll)
+
+      return () => {
+        window.removeEventListener('resize', resetSlidesToScroll)
+      }
+    }, [])
+
+    useEffect(() => {
+      if (!glider) return
+      gliderInstanceRef.current = glider
+    }, [glider])
 
-export const Carousel: React.FC<CarouselProps> = ({
-  children,
-  paddingLeft = 0,
-  paddingTop = 0,
-  className = '',
-  arrowPosition,
-  slidesToShow = 'auto',
-  ...gliderOptions
-}) => {
-  // TODO: this is the only place in the app that requires refs to buttons. Once we refactor this component, we can remove forwardRef from buttons
-  const nextArrowRef = useRef<HTMLButtonElement>(null)
-  const prevArrowRef = useRef<HTMLButtonElement>(null)
-  const { ref, getContainerProps, getGliderProps, getTrackProps, getPrevArrowProps, getNextArrowProps } = useGlider<
-    HTMLDivElement
-  >({
-    slidesToShow,
-    arrows: { prev: prevArrowRef.current, next: nextArrowRef.current },
-    ...gliderOptions,
-  })
-
-  return (
-    <Container {...getContainerProps({ className })}>
-      <Arrow {...getPrevArrowProps()} ref={prevArrowRef} arrowPosition={arrowPosition} size="large">
-        <SvgGlyphChevronLeft />
-      </Arrow>
-      <BackgroundGradient direction="prev" paddingLeft={paddingLeft} paddingTop={paddingTop} />
-      <GliderContainer {...getGliderProps()} paddingLeft={paddingLeft} paddingTop={paddingTop} ref={ref}>
-        <Track {...getTrackProps()}>{children}</Track>
-      </GliderContainer>
-      <Arrow {...getNextArrowProps()} ref={nextArrowRef} arrowPosition={arrowPosition} size="large">
-        <SvgGlyphChevronRight />
-      </Arrow>
-      <BackgroundGradient direction="next" paddingLeft={paddingLeft} paddingTop={paddingTop} />
-    </Container>
-  )
-}
+    useImperativeHandle(ref, () => ({
+      getPrevArrowProps,
+      getNextArrowProps,
+    }))
+    return (
+      <Container {...getContainerProps({ className })}>
+        <GliderContainer {...getGliderProps()} ref={gliderRef}>
+          <Track {...getTrackProps()}>{children}</Track>
+        </GliderContainer>
+        {dotsVisible && <Dots {...getDotsProps()} ref={dotsRef} />}
+      </Container>
+    )
+  }
+)

+ 0 - 27
src/shared/components/ChannelCardBase/ChannelCard.stories.tsx

@@ -1,27 +0,0 @@
-import { Meta, Story } from '@storybook/react'
-import React from 'react'
-import { BrowserRouter } from 'react-router-dom'
-
-import { ChannelCardBase, ChannelCardBaseProps } from './ChannelCardBase'
-
-export default {
-  title: 'Shared/C/ChannelCard',
-  component: ChannelCardBase,
-  argTypes: {
-    className: { table: { disable: true } },
-    onClick: { table: { disable: true } },
-  },
-  decorators: [(story) => <BrowserRouter>{story()}</BrowserRouter>],
-} as Meta
-
-const Template: Story<ChannelCardBaseProps> = (args) => <ChannelCardBase {...args} />
-const SkeletonLoaderTemplate: Story<ChannelCardBaseProps> = (args) => <ChannelCardBase {...args} />
-
-export const Regular = Template.bind({})
-Regular.args = {
-  title: 'Test channel',
-  assetUrl: 'https://eu-central-1.linodeobjects.com/atlas-assets/channel-avatars/2.jpg',
-  videoCount: 0,
-  loading: false,
-}
-export const SkeletonLoader = SkeletonLoaderTemplate.bind({})

+ 33 - 0
src/shared/components/ChannelCardBase/ChannelCardBase.stories.tsx

@@ -0,0 +1,33 @@
+import { Meta, Story } from '@storybook/react'
+import React from 'react'
+import { BrowserRouter } from 'react-router-dom'
+
+import { ChannelCardBase, ChannelCardBaseProps } from './ChannelCardBase'
+
+export default {
+  title: 'Shared/C/ChannelCard',
+  component: ChannelCardBase,
+  args: {
+    avatarUrl: 'https://eu-central-1.linodeobjects.com/atlas-assets/channel-avatars/2.jpg',
+    title: 'Example Channel',
+    id: '3',
+    follows: 200,
+  },
+  decorators: [
+    (Story) => (
+      <BrowserRouter>
+        <Story />
+      </BrowserRouter>
+    ),
+  ],
+} as Meta
+
+const Template: Story<ChannelCardBaseProps> = (args) => {
+  return (
+    <>
+      <ChannelCardBase {...args} />
+    </>
+  )
+}
+
+export const Default = Template.bind({})

+ 63 - 0
src/shared/components/ChannelCardBase/ChannelCardBase.style.ts

@@ -0,0 +1,63 @@
+import styled from '@emotion/styled'
+import { Link } from 'react-router-dom'
+
+import { colors, sizes, transitions } from '@/shared/theme'
+
+import { Avatar } from '../Avatar'
+import { Button } from '../Button'
+import { Text } from '../Text'
+
+export const ChannelCardArticle = styled.article`
+  position: relative;
+  display: flex;
+
+  :hover:not(:active) {
+    ${() => ChannelCardAnchor} {
+      transform: translate(-${sizes(2)}, -${sizes(2)});
+      box-shadow: ${sizes(2)} ${sizes(2)} 0 ${colors.blue['500']};
+    }
+  }
+`
+
+export const ChannelCardAnchor = styled(Link)`
+  width: 100%;
+  text-decoration: none;
+  align-items: center;
+  transition: transform, box-shadow;
+  transition-duration: ${transitions.timings.regular};
+  transition-timing-function: ${transitions.easing};
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+  background-color: ${colors.gray[900]};
+  padding: ${sizes(4)} 0;
+`
+
+export const StyledAvatar = styled(Avatar)`
+  margin-bottom: ${sizes(4)};
+`
+
+export const InfoWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`
+
+export const ChannelTitle = styled(Text)`
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const ChannelFollows = styled(Text)`
+  margin-top: ${sizes(1)};
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const FollowButton = styled(Button)`
+  margin-top: ${sizes(4)};
+`

+ 0 - 96
src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx

@@ -1,96 +0,0 @@
-import { css } from '@emotion/react'
-import styled from '@emotion/styled'
-import { Link } from 'react-router-dom'
-
-import { colors, sizes, transitions, typography } from '../../theme'
-import { Avatar } from '../Avatar'
-import { Text } from '../Text'
-
-const imageTopOverflow = '2rem'
-const containerPadding = '22px'
-
-export const OuterContainer = styled.article`
-  display: flex;
-  min-height: calc(178px + ${imageTopOverflow});
-  padding-top: ${imageTopOverflow};
-
-  :hover {
-    cursor: ${(props) => (props.onClick ? 'pointer' : 'default')};
-  }
-`
-
-type InnerContainerProps = {
-  animated: boolean
-}
-const hoverTransition = ({ animated }: InnerContainerProps) =>
-  animated
-    ? css`
-        transition: all 0.4s ${transitions.easing};
-
-        &:hover {
-          transform: translate3d(-${sizes(2)}, -${sizes(2)}, 0);
-          border: 1px solid ${colors.white};
-          box-shadow: ${sizes(2)} ${sizes(2)} 0 ${colors.blue[500]};
-        }
-      `
-    : null
-
-export const InnerContainer = styled.div<InnerContainerProps>`
-  background-color: ${colors.gray[800]};
-  color: ${colors.gray[300]};
-  width: calc(156px + calc(2 * ${containerPadding}));
-  padding: 0 ${containerPadding} ${sizes(3)} ${containerPadding};
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  border: 1px solid transparent;
-  ${hoverTransition}
-`
-
-export const Anchor = styled(Link)`
-  all: unset;
-  color: inherit;
-`
-
-export const Info = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  text-align: center;
-  margin-top: ${sizes(3)};
-  padding: 0 ${sizes(1)};
-  max-width: 100%;
-`
-
-export const VideoCountContainer = styled.div`
-  margin-top: ${sizes(2)};
-`
-
-export const AvatarContainer = styled.div`
-  width: 100%;
-  height: 156px;
-  position: relative;
-  margin-top: -${imageTopOverflow};
-  z-index: 2;
-`
-
-export const TextBase = styled(Text)`
-  line-height: 1.25;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 100%;
-`
-
-export const VideoCount = styled(TextBase)`
-  color: ${colors.gray[300]};
-`
-
-export const StyledAvatar = styled(Avatar)`
-  width: 100%;
-  height: 100%;
-
-  span {
-    font-size: ${typography.sizes.h2};
-  }
-`

+ 47 - 66
src/shared/components/ChannelCardBase/ChannelCardBase.tsx

@@ -1,88 +1,69 @@
 import React from 'react'
-import { CSSTransition, SwitchTransition } from 'react-transition-group'
 
-import { transitions } from '@/shared/theme'
+import { absoluteRoutes } from '@/config/routes'
+import { formatNumberShort } from '@/utils/number'
 
 import {
-  Anchor,
-  AvatarContainer,
-  Info,
-  InnerContainer,
-  OuterContainer,
+  ChannelCardAnchor,
+  ChannelCardArticle,
+  ChannelFollows,
+  ChannelTitle,
+  FollowButton,
+  InfoWrapper,
   StyledAvatar,
-  TextBase,
-  VideoCount,
-  VideoCountContainer,
 } from './ChannelCardBase.style'
 
 import { SkeletonLoader } from '../SkeletonLoader'
 
 export type ChannelCardBaseProps = {
-  assetUrl?: string | null
+  id?: string | null
+  isLoading?: boolean
   title?: string | null
-  videoCount?: number
-  channelHref?: string
+  follows?: number | null
+  avatarUrl?: string | null
+  isFollowing?: boolean
+  onFollow?: (event: React.MouseEvent) => void
   className?: string
-  loading?: boolean
-  onClick?: (e: React.MouseEvent<HTMLElement>) => void
+  onClick?: () => void
 }
 
 export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
-  assetUrl,
+  id,
+  isLoading,
   title,
-  videoCount,
-  loading = true,
-  channelHref,
+  follows,
+  avatarUrl,
+  isFollowing,
+  onFollow,
   className,
   onClick,
 }) => {
-  const isAnimated = !loading && !!channelHref
-  const handleClick = (e: React.MouseEvent<HTMLElement>) => {
-    if (!onClick) return
-    onClick(e)
-  }
-  const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
-    if (!channelHref) {
-      e.preventDefault()
-    }
-  }
   return (
-    <OuterContainer className={className} onClick={handleClick}>
-      <Anchor to={channelHref ?? ''} onClick={handleAnchorClick}>
-        <SwitchTransition>
-          <CSSTransition
-            key={loading ? 'placeholder' : 'content'}
-            timeout={parseInt(transitions.timings.loading) * 0.75}
-            classNames={transitions.names.fade}
-          >
-            <InnerContainer animated={isAnimated}>
-              <AvatarContainer>
-                {loading ? <SkeletonLoader rounded /> : <StyledAvatar assetUrl={assetUrl} />}
-              </AvatarContainer>
-              <Info>
-                {loading ? (
-                  <SkeletonLoader width="140px" height="16px" />
-                ) : (
-                  <TextBase variant="h6">{title || '\u00A0'}</TextBase>
-                )}
-                <VideoCountContainer>
-                  {loading ? (
-                    <SkeletonLoader width="140px" height="16px" />
-                  ) : (
-                    <CSSTransition
-                      in={!!videoCount}
-                      timeout={parseInt(transitions.timings.loading) * 0.5}
-                      classNames={transitions.names.fade}
-                    >
-                      <VideoCount variant="subtitle2">{`${videoCount ?? ''} Uploads`}</VideoCount>
-                    </CSSTransition>
-                  )}
-                </VideoCountContainer>
-              </Info>
-            </InnerContainer>
-          </CSSTransition>
-        </SwitchTransition>
-      </Anchor>
-    </OuterContainer>
+    <ChannelCardArticle className={className}>
+      <ChannelCardAnchor onClick={onClick} to={absoluteRoutes.viewer.channel(id || '')}>
+        <StyledAvatar size="channel-card" loading={isLoading} assetUrl={avatarUrl} />
+        <InfoWrapper>
+          {isLoading ? (
+            <SkeletonLoader width="100px" height="20px" bottomSpace="4px" />
+          ) : (
+            <ChannelTitle variant="h6">{title}</ChannelTitle>
+          )}
+          {isLoading ? (
+            <SkeletonLoader width="70px" height="20px" bottomSpace="16px" />
+          ) : (
+            <ChannelFollows variant="body2" secondary>
+              {formatNumberShort(follows || 0)} followers
+            </ChannelFollows>
+          )}
+          {isLoading ? (
+            <SkeletonLoader width="60px" height="30px" />
+          ) : (
+            <FollowButton variant="secondary" size="small" onClick={onFollow}>
+              {isFollowing ? 'Unfollow' : 'Follow'}
+            </FollowButton>
+          )}
+        </InfoWrapper>
+      </ChannelCardAnchor>
+    </ChannelCardArticle>
   )
 }

+ 1 - 2
src/shared/components/ContextMenu/ContextMenu.style.ts

@@ -16,7 +16,6 @@ export const StyledContainer = styled.div<ContainerProps>`
   background-color: ${colors.gray[800]};
   width: 200px;
   color: ${colors.white};
-  padding: ${sizes(2)};
   word-break: break-all;
 
   &.menu-enter {
@@ -45,7 +44,7 @@ export const StyledContainer = styled.div<ContainerProps>`
 export const StyledMenuItem = styled.div`
   display: flex;
   align-items: center;
-  padding: ${sizes(3)};
+  padding: ${sizes(4)};
   transition: background-color 200ms ${transitions.easing};
 
   &:hover {

+ 3 - 2
src/shared/components/EmptyFallback/EmptyFallback.tsx

@@ -11,6 +11,7 @@ export type EmptyFallbackProps = {
   subtitle?: string | null
   variant?: EmptyFallbackSizes
   button?: ReactNode
+  className?: string
 }
 
 const ILLUSTRATION_SIZES = {
@@ -24,8 +25,8 @@ const ILLUSTRATION_SIZES = {
   },
 }
 
-export const EmptyFallback: FC<EmptyFallbackProps> = ({ title, subtitle, variant = 'large', button }) => (
-  <Container variant={variant}>
+export const EmptyFallback: FC<EmptyFallbackProps> = ({ title, subtitle, variant = 'large', button, className }) => (
+  <Container className={className} variant={variant}>
     <SvgEmptyStateIllustration width={ILLUSTRATION_SIZES[variant].width} height={ILLUSTRATION_SIZES[variant].height} />
     <Message>
       {title && <Title variant={variant === 'large' ? 'h4' : 'body1'}>{title}</Title>}

+ 2 - 2
src/shared/components/FormField/FormField.style.ts

@@ -5,7 +5,7 @@ import { colors, sizes, typography } from '@/shared/theme'
 import { Text } from '../Text'
 
 export const FormFieldWrapper = styled.div`
-  margin-top: ${sizes(2)};
+  margin-top: ${sizes(6)};
   width: 100%;
   max-width: 760px;
 `
@@ -27,5 +27,5 @@ export const FormFieldDescription = styled(Text)`
 `
 
 export const ChildrenWrapper = styled.div`
-  margin-top: ${sizes(5)};
+  margin-top: ${sizes(6)};
 `

+ 12 - 14
src/shared/components/Gallery/Gallery.style.ts

@@ -1,24 +1,22 @@
 import styled from '@emotion/styled'
 
-import { sizes, typography } from '../../theme'
+import { Button } from '@/shared/components'
+
+import { sizes } from '../../theme'
 
 export const Container = styled.section`
   display: flex;
   flex-direction: column;
 `
-export const HeadingContainer = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: baseline;
-  margin-bottom: ${sizes(4)};
 
-  > h4 {
-    font-size: ${typography.sizes.h5};
-    margin: 0;
-  }
+export const CarouselArrowsContainer = styled.div`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-column-gap: ${sizes(4)};
+  margin-left: auto;
+`
 
-  > button {
-    font-size: ${typography.sizes.subtitle2};
-    padding: 0;
-  }
+export const SeeAllLink = styled(Button)`
+  flex-shrink: 0;
+  margin-left: ${sizes(8)};
 `

+ 52 - 9
src/shared/components/Gallery/Gallery.tsx

@@ -1,24 +1,67 @@
-import React from 'react'
+import React, { ComponentProps, useRef } from 'react'
 
-import { Container, HeadingContainer } from './Gallery.style'
+import { TitleContainer } from '@/shared/components'
+import { Arrow } from '@/shared/components/Carousel/Carousel.style'
+import { SvgGlyphChevronLeft, SvgGlyphChevronRight, SvgPlayerPlay } from '@/shared/icons'
+
+import { CarouselArrowsContainer, Container, SeeAllLink } from './Gallery.style'
 
 import { Carousel, CarouselProps } from '../Carousel/Carousel'
+import { GridHeadingContainer } from '../GridHeading'
 import { Text } from '../Text'
 
 export type GalleryProps = {
   title?: string
   className?: string
+  seeAllUrl?: string
 } & CarouselProps
 
-export const Gallery: React.FC<GalleryProps> = ({ title, className, ...carouselProps }) => {
+type ImperativeHandleData = {
+  getPrevArrowProps: () => ComponentProps<typeof Arrow>
+  getNextArrowProps: () => ComponentProps<typeof Arrow>
+}
+
+export const Gallery: React.FC<GalleryProps> = ({ title, className, seeAllUrl, ...carouselProps }) => {
+  // TODO: this is the only place in the app that requires refs to buttons. Once we refactor this component, we can remove forwardRef from buttons
+  const prevArrowRef = useRef<HTMLButtonElement>(null)
+  const nextArrowRef = useRef<HTMLButtonElement>(null)
+  const carouselRef = useRef<ImperativeHandleData>(null)
   return (
     <Container className={className}>
-      {title && (
-        <HeadingContainer>
-          <Text variant="h5">{title}</Text>
-        </HeadingContainer>
-      )}
-      <Carousel {...carouselProps} />
+      <GridHeadingContainer>
+        <TitleContainer>
+          {title && <Text variant="h4">{title}</Text>}
+          {seeAllUrl && (
+            <>
+              <SeeAllLink
+                iconPlacement="left"
+                icon={<SvgPlayerPlay width={16} height={16} />}
+                textOnly
+                to={seeAllUrl}
+                size="large"
+                variant="primary"
+              >
+                See all
+              </SeeAllLink>
+            </>
+          )}
+          <CarouselArrowsContainer>
+            <Arrow {...carouselRef.current?.getPrevArrowProps()} ref={prevArrowRef} size="large" variant="secondary">
+              <SvgGlyphChevronLeft />
+            </Arrow>
+            <Arrow {...carouselRef.current?.getNextArrowProps()} ref={nextArrowRef} size="large" variant="secondary">
+              <SvgGlyphChevronRight />
+            </Arrow>
+          </CarouselArrowsContainer>
+        </TitleContainer>
+      </GridHeadingContainer>
+      <Carousel
+        {...carouselProps}
+        prevArrowRef={prevArrowRef}
+        nextArrowRef={nextArrowRef}
+        ref={carouselRef}
+        itemWidth={350}
+      />
     </Container>
   )
 }

+ 2 - 0
src/shared/components/Glider/Glider.tsx

@@ -28,6 +28,7 @@ const getTrackProps = getPropsFor('glider-track')
 const getNextArrowProps = getPropsFor('glider-next')
 const getPrevArrowProps = getPropsFor('glider-prev')
 const getContainerProps = getPropsFor('glider-contain')
+const getDotsProps = getPropsFor('glider-dots')
 
 export function useGlider<T extends HTMLElement>({
   onAdd,
@@ -80,6 +81,7 @@ export function useGlider<T extends HTMLElement>({
     getNextArrowProps,
     getPrevArrowProps,
     getContainerProps,
+    getDotsProps,
   }
 }
 

+ 18 - 0
src/shared/components/GridHeading/GridHeading.styles.ts

@@ -0,0 +1,18 @@
+import styled from '@emotion/styled'
+
+import { colors, sizes } from '@/shared/theme'
+
+export const GridHeadingContainer = styled.div`
+  display: flex;
+  align-items: center;
+  padding-bottom: ${sizes(5)};
+  border-bottom: 1px solid ${colors.gray[700]};
+  margin-bottom: ${sizes(12)};
+`
+
+export const TitleContainer = styled.div`
+  display: flex;
+  width: 100%;
+  min-height: 40px;
+  align-items: center;
+`

+ 1 - 0
src/shared/components/GridHeading/index.ts

@@ -0,0 +1 @@
+export * from './GridHeading.styles'

+ 2 - 1
src/shared/components/HelperText/HelperText.style.ts

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled'
 
-import { colors, typography } from '@/shared/theme'
+import { colors, sizes, typography } from '@/shared/theme'
 
 import { Text } from '../Text'
 
@@ -21,6 +21,7 @@ export const StyledHelperText = styled(Text)<HelperTextProps>`
 `
 
 export const HelperTextsWrapper = styled.div`
+  min-height: ${sizes(3.5)};
   margin-left: 4px;
   margin-top: 8px;
   font-size: ${typography.sizes.body2};

+ 56 - 0
src/shared/components/LayoutGrid/LayoutGrid.tsx

@@ -0,0 +1,56 @@
+import { css } from '@emotion/react'
+import styled from '@emotion/styled'
+
+import { media } from '@/shared/theme'
+
+type ReponsivenessObject = Partial<Record<keyof typeof media, number>>
+
+export const LayoutGrid = styled.div`
+  display: grid;
+  grid-template-columns: repeat(12, 1fr);
+  grid-gap: 16px;
+
+  ${media.medium} {
+    grid-gap: 24px;
+  }
+`
+
+type GridItemProps = {
+  colStart?: number | 'initial' | ReponsivenessObject
+  colSpan?: number | ReponsivenessObject
+  rowStart?: number | 'initial' | ReponsivenessObject
+  rowSpan?: number | ReponsivenessObject
+}
+
+function isResponsivenessObject(prop?: number | 'initial' | ReponsivenessObject): prop is ReponsivenessObject {
+  return !!prop && typeof prop !== 'number' && prop !== 'initial'
+}
+
+const createBreakpointGridItemRules = (breakpointKey: keyof ReponsivenessObject) => ({
+  colStart,
+  colSpan,
+  rowStart,
+  rowSpan,
+}: GridItemProps) => css`
+  ${media[breakpointKey]} {
+    ${isResponsivenessObject(colStart) && colStart[breakpointKey] && `grid-column-start: ${colStart[breakpointKey]};`}
+    ${isResponsivenessObject(colSpan) && colSpan[breakpointKey] && `grid-column-end: span ${colSpan[breakpointKey]};`}
+    ${isResponsivenessObject(rowStart) && rowStart[breakpointKey] && `grid-row-start: ${rowStart[breakpointKey]};`}
+    ${isResponsivenessObject(rowSpan) && rowSpan[breakpointKey] && `grid-row-end: span ${rowSpan[breakpointKey]};`}
+  }
+`
+
+export const GridItem = styled.div<GridItemProps>`
+  ${({ colStart }) => !isResponsivenessObject(colStart) && colStart && `grid-column-start: ${colStart};`}
+  ${({ colSpan }) => !isResponsivenessObject(colSpan) && colSpan && `grid-column-end: span ${colSpan};`}
+  ${({ rowStart }) => !isResponsivenessObject(rowStart) && rowStart && `grid-row-start: ${rowStart};`}
+  ${({ rowSpan }) => !isResponsivenessObject(rowSpan) && rowSpan && `grid-row-end: span ${rowSpan};`}
+  
+  ${createBreakpointGridItemRules('base')}
+  ${createBreakpointGridItemRules('compact')}
+  ${createBreakpointGridItemRules('small')}
+  ${createBreakpointGridItemRules('medium')}
+  ${createBreakpointGridItemRules('large')}
+  ${createBreakpointGridItemRules('xlarge')}
+  ${createBreakpointGridItemRules('xxlarge')}
+`

+ 29 - 0
src/shared/components/LoadMoreButton/LoadMoreButton.tsx

@@ -0,0 +1,29 @@
+import styled from '@emotion/styled'
+import React, { FC, MouseEvent } from 'react'
+
+import { Button } from '@/shared/components'
+import { SvgGlyphChevronDown } from '@/shared/icons'
+
+type LoadMoreButtonProps = {
+  onClick: (event: MouseEvent<HTMLButtonElement>) => void
+  label?: string
+}
+
+export const LoadMoreButton: FC<LoadMoreButtonProps> = ({ onClick, label = 'Show more videos' }) => (
+  <LoadMore
+    variant="tertiary"
+    size="large"
+    fullWidth
+    onClick={onClick}
+    iconPlacement="right"
+    icon={<SvgGlyphChevronDown width={12} height={12} />}
+  >
+    {label}
+  </LoadMore>
+)
+
+const LoadMore = styled(Button)`
+  span {
+    display: flex;
+  }
+`

+ 1 - 0
src/shared/components/LoadMoreButton/index.ts

@@ -0,0 +1 @@
+export * from './LoadMoreButton'

+ 3 - 1
src/shared/components/MultiFileSelect/MultiFileSelect.tsx

@@ -54,6 +54,7 @@ export type MultiFileSelectProps = {
   editMode?: boolean
   onError?: (error: FileErrorType | null) => void
   error?: string | null
+  className?: string
 }
 
 export const MultiFileSelect: React.FC<MultiFileSelectProps> = ({
@@ -65,6 +66,7 @@ export const MultiFileSelect: React.FC<MultiFileSelectProps> = ({
   editMode = false,
   onError,
   error,
+  className,
 }) => {
   const dialogRef = useRef<ImageCropDialogImperativeHandle>(null)
   const [step, setStep] = useState<FileType>('video')
@@ -182,7 +184,7 @@ export const MultiFileSelect: React.FC<MultiFileSelectProps> = ({
   }
 
   return (
-    <MultiFileSelectContainer>
+    <MultiFileSelectContainer className={className}>
       <FileSelect
         maxSize={step === 'video' ? maxVideoSize : maxImageSize}
         onUploadFile={handleUploadFile}

+ 74 - 0
src/shared/components/RankingNumberTile/RankingNumberTile.style.ts

@@ -0,0 +1,74 @@
+import { css } from '@emotion/react'
+import styled from '@emotion/styled'
+
+import { colors, media, sizes } from '@/shared/theme'
+
+export const RankingNumberTileWrapper = styled.div`
+  position: relative;
+  display: flex;
+  justify-content: flex-end;
+`
+
+const variantStyles = (variant: 'channel' | 'video') => {
+  switch (variant) {
+    case 'channel':
+      return css`
+        height: 100%;
+        top: -${sizes(5)};
+        ${media.medium} {
+          top: 0;
+          align-items: flex-start;
+          font-size: 140px;
+        }
+        ${media.xlarge} {
+          font-size: 180px;
+        }
+      `
+    case 'video':
+      return css`
+        height: 50%;
+        ${media.medium} {
+          line-height: 0.7;
+          font-size: 140px;
+        }
+        ${media.xlarge} {
+          font-size: 180px;
+        }
+      `
+    default:
+      return null
+  }
+}
+
+type RankingNumberProps = {
+  variant: 'channel' | 'video'
+}
+export const RankingNumber = styled.div<RankingNumberProps>`
+  position: absolute;
+  line-height: 1;
+  z-index: -5;
+  left: 0;
+  color: black;
+  font-weight: 700;
+  font-size: 100px;
+  -webkit-text-stroke-width: 4px;
+  -webkit-text-stroke-color: ${colors.gray[500]};
+  font-family: 'PxGrotesk', sans-serif;
+  letter-spacing: -0.17em;
+  display: flex;
+  align-items: center;
+
+  ${({ variant }) => variantStyles(variant)};
+`
+
+export const ChildrenWrapper = styled.div`
+  --ranking-number-gap: 48px;
+
+  width: calc(100% - var(--ranking-number-gap));
+  ${media.medium} {
+    --ranking-number-gap: 72px;
+  }
+  ${media.xlarge} {
+    --ranking-number-gap: 92px;
+  }
+`

Някои файлове не бяха показани, защото твърде много файлове са промени