Pārlūkot izejas kodu

merge channel view, player fixes and various tweaks

merge channel view, player fixes and various tweaks
Klaudiusz Dembler 3 gadi atpakaļ
vecāks
revīzija
0ef542dabe
100 mainītis faili ar 1160 papildinājumiem un 537 dzēšanām
  1. 0 0
      docs/video-hero.md
  2. 4 2
      src/MainLayout.tsx
  3. 8 10
      src/api/client/cache.ts
  4. 212 37
      src/api/client/resolvers.ts
  5. 16 2
      src/api/client/transforms/index.ts
  6. 38 0
      src/api/client/transforms/orionFollows.ts
  7. 83 3
      src/api/client/transforms/orionViews.ts
  8. 24 0
      src/api/client/transforms/queryNodeViews.ts
  9. 2 1
      src/api/hooks/channel.ts
  10. 3 0
      src/api/queries/__generated__/baseTypes.generated.ts
  11. 173 2
      src/api/queries/__generated__/channels.generated.tsx
  12. 4 2
      src/api/queries/__generated__/search.generated.tsx
  13. 29 3
      src/api/queries/channels.graphql
  14. 2 2
      src/api/queries/search.graphql
  15. 2 1
      src/api/schemas/extendedQueryNode.graphql
  16. 5 5
      src/components/ChannelCard.tsx
  17. 3 3
      src/components/ChannelGallery.tsx
  18. 3 3
      src/components/ChannelGrid.tsx
  19. 2 2
      src/components/ChannelLink/ChannelLink.style.ts
  20. 2 2
      src/components/ChannelLink/ChannelLink.tsx
  21. 0 1
      src/components/CoverVideo/index.ts
  22. 40 7
      src/components/Dialogs/ActionDialog/ActionDialog.stories.tsx
  23. 0 62
      src/components/Dialogs/ActionDialog/ActionDialog.style.ts
  24. 20 32
      src/components/Dialogs/ActionDialog/ActionDialog.tsx
  25. 1 0
      src/components/Dialogs/BaseDialog/BaseDialog.style.ts
  26. 4 4
      src/components/Dialogs/ImageCropDialog/ImageCropDialog.stories.tsx
  27. 2 2
      src/components/Dialogs/ImageCropDialog/ImageCropDialog.style.ts
  28. 1 2
      src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx
  29. 1 2
      src/components/Dialogs/ImageCropDialog/cropper.ts
  30. 1 3
      src/components/Dialogs/TransactionDialog/TransactionDialog.tsx
  31. 3 3
      src/components/InfiniteGrids/InfiniteChannelGrid.tsx
  32. 5 5
      src/components/InfiniteGrids/InfiniteVideoGrid.tsx
  33. 3 3
      src/components/LimitedWidthContainer.tsx
  34. 0 18
      src/components/PlaceholderVideoGrid.tsx
  35. 18 0
      src/components/SkeletonLoaderVideoGrid.tsx
  36. 2 2
      src/components/Topbar/StudioTopbar/StudioTopbar.style.tsx
  37. 7 7
      src/components/Topbar/StudioTopbar/StudioTopbar.tsx
  38. 6 6
      src/components/VideoGallery.tsx
  39. 3 3
      src/components/VideoGrid.tsx
  40. 4 20
      src/components/VideoHero/VideoHero.style.ts
  41. 10 13
      src/components/VideoHero/VideoHero.tsx
  42. 3 3
      src/components/VideoHero/VideoHeroData.ts
  43. 0 0
      src/components/VideoHero/backupVideoHeroInfo.json
  44. 1 0
      src/components/VideoHero/index.ts
  45. 14 19
      src/components/VideoTile.tsx
  46. 5 5
      src/components/index.ts
  47. 6 0
      src/config/sorting.ts
  48. 11 7
      src/hooks/useDeleteVideo.tsx
  49. 12 8
      src/hooks/useDisplayDataLostWarning.tsx
  50. 2 2
      src/mocking/accessors/filtering.ts
  51. 15 0
      src/mocking/accessors/orion.ts
  52. 4 4
      src/mocking/data/mockChannels.ts
  53. 8 8
      src/mocking/data/mockVideos.ts
  54. 0 0
      src/mocking/data/raw/VideoHero.json
  55. 9 0
      src/mocking/handlers.ts
  56. 5 0
      src/mocking/store.ts
  57. 5 1
      src/mocking/types.ts
  58. 9 5
      src/providers/transactionManager/useTransaction.ts
  59. 2 2
      src/shared/components/ActionBar/ActionBarTransaction.style.ts
  60. 5 5
      src/shared/components/ActionBar/ActionBarTransaction.tsx
  61. 16 3
      src/shared/components/Avatar/Avatar.style.tsx
  62. 2 2
      src/shared/components/Avatar/Avatar.tsx
  63. 2 2
      src/shared/components/CategoryPicker/CategoryPicker.style.ts
  64. 2 2
      src/shared/components/CategoryPicker/CategoryPicker.tsx
  65. 6 6
      src/shared/components/ChannelCardBase/ChannelCard.stories.tsx
  66. 0 0
      src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx
  67. 8 8
      src/shared/components/ChannelCardBase/ChannelCardBase.tsx
  68. 1 0
      src/shared/components/ChannelCardBase/index.ts
  69. 3 93
      src/shared/components/ChannelCover/ChannelCover.style.ts
  70. 1 1
      src/shared/components/ChannelCover/ChannelCover.tsx
  71. 0 1
      src/shared/components/ChannelPreviewBase/index.ts
  72. 0 1
      src/shared/components/Checkout/index.tsx
  73. 7 7
      src/shared/components/CircularProgress/CircularProgress.stories.tsx
  74. 0 0
      src/shared/components/CircularProgress/CircularProgress.style.tsx
  75. 4 4
      src/shared/components/CircularProgress/CircularProgress.tsx
  76. 1 1
      src/shared/components/CircularProgress/Path.tsx
  77. 1 0
      src/shared/components/CircularProgress/index.ts
  78. 0 1
      src/shared/components/CircularProgressbar/index.ts
  79. 5 5
      src/shared/components/DismissibleBanner/DismissibleBanner.stories.tsx
  80. 2 2
      src/shared/components/DismissibleBanner/DismissibleBanner.tsx
  81. 1 0
      src/shared/components/DismissibleBanner/index.ts
  82. 0 1
      src/shared/components/DismissibleMessage/index.ts
  83. 38 0
      src/shared/components/EmptyFallback/EmptyFallback.stories.tsx
  84. 40 0
      src/shared/components/EmptyFallback/EmptyFallback.styles.ts
  85. 40 0
      src/shared/components/EmptyFallback/EmptyFallback.tsx
  86. 1 0
      src/shared/components/EmptyFallback/index.ts
  87. 2 2
      src/shared/components/FileStep/FileStep.style.ts
  88. 2 2
      src/shared/components/Grid/Grid.tsx
  89. 0 1
      src/shared/components/HeaderTextField/index.ts
  90. 0 32
      src/shared/components/Placeholder/Placeholder.tsx
  91. 0 1
      src/shared/components/Placeholder/index.ts
  92. 5 5
      src/shared/components/ProgressDrawer/ProgressDrawer.stories.tsx
  93. 2 2
      src/shared/components/ProgressDrawer/ProgressDrawer.styles.tsx
  94. 5 5
      src/shared/components/ProgressDrawer/ProgressDrawer.tsx
  95. 1 0
      src/shared/components/ProgressDrawer/index.tsx
  96. 1 1
      src/shared/components/Select/Select.style.ts
  97. 31 0
      src/shared/components/SkeletonLoader/SkeletonLoader.stories.tsx
  98. 62 0
      src/shared/components/SkeletonLoader/SkeletonLoader.tsx
  99. 1 0
      src/shared/components/SkeletonLoader/index.ts
  100. 5 2
      src/shared/components/Tabs/Tabs.styles.tsx

+ 0 - 0
docs/cover-video.md → docs/video-hero.md


+ 4 - 2
src/MainLayout.tsx

@@ -29,8 +29,10 @@ export const MainLayout: React.FC = () => {
     description:
       'It seems the browser version you are using is not fully supported by Joystream. Some of the features may be broken or not accessible. For the best experience, please upgrade your browser to the latest version.',
     variant: 'warning',
-    primaryButtonText: 'Click here to see instructions',
-    onPrimaryButtonClick: () => window.open('https://www.whatismybrowser.com/guides/how-to-update-your-browser/auto'),
+    primaryButton: {
+      text: 'Click here to see instructions',
+      onClick: () => window.open('https://www.whatismybrowser.com/guides/how-to-update-your-browser/auto'),
+    },
     onExitClick: () => closeDialog(),
   })
 

+ 8 - 10
src/api/client/cache.ts

@@ -8,28 +8,29 @@ import {
   AllChannelFieldsFragment,
   AssetAvailability,
   GetVideosConnectionQueryVariables,
-  GetVideosQueryVariables,
   Query,
   VideoConnection,
   VideoFieldsFragment,
   VideoOrderByInput,
 } from '../queries'
 
-const getVideoKeyArgs = (args: Record<string, GetVideosQueryVariables['where']> | null) => {
+const getVideoKeyArgs = (args: GetVideosConnectionQueryVariables | null) => {
   // make sure queries asking for a specific category are separated in cache
+  const onlyCount = args?.first === 0
   const channelId = args?.where?.channelId_eq || ''
   const categoryId = args?.where?.categoryId_eq || ''
   const idEq = args?.where?.id_eq || ''
   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] : ''
 
   // only for counting videos in HomeView
   if (args?.where?.channelId_in && !args?.first) {
     return `${createdAtGte}:${channelIdIn}`
   }
 
-  return `${channelId}:${categoryId}:${channelIdIn}:${createdAtGte}:${isPublic}:${idEq}`
+  return `${onlyCount}:${channelId}:${categoryId}:${channelIdIn}:${createdAtGte}:${isPublic}:${idEq}:${sorting}`
 }
 
 const createDateHandler = () => ({
@@ -96,13 +97,10 @@ const queryCacheFields: CachePolicyFields<keyof Query> = {
       const filteredEdges =
         existing?.edges.filter((edge) => readField('isPublic', edge.node) === isPublic || isPublic === undefined) ?? []
 
-      const sortingASC = args?.orderBy === VideoOrderByInput.CreatedAtAsc
-      const preSortedDESC = (filteredEdges || [])
-        .slice()
-        .sort(
-          (a, b) =>
-            (readField('createdAt', b.node) as Date).getTime() - (readField('createdAt', a.node) as Date).getTime()
-        )
+      const sortingASC = args?.orderBy?.[0] === VideoOrderByInput.CreatedAtAsc
+      const preSortedDESC = (filteredEdges || []).slice().sort((a, b) => {
+        return (readField('createdAt', b.node) as Date).getTime() - (readField('createdAt', a.node) as Date).getTime()
+      })
       const sortedEdges = sortingASC ? preSortedDESC.reverse() : preSortedDESC
 
       return (

+ 212 - 37
src/api/client/resolvers.ts

@@ -6,28 +6,43 @@ import { createLookup } from '@/utils/data'
 import { Logger } from '@/utils/logger'
 
 import {
+  ORION_BATCHED_FOLLOWS_QUERY_NAME,
   ORION_BATCHED_VIEWS_QUERY_NAME,
   ORION_FOLLOWS_QUERY_NAME,
+  ORION_VIEWS_QUERY_NAME,
   RemoveQueryNodeFollowsField,
   RemoveQueryNodeViewsField,
+  TransformBatchedOrionFollowsField,
   TransformBatchedOrionViewsField,
   TransformOrionFollowsField,
+  TransformOrionViewsField,
 } from './transforms'
-import { ORION_VIEWS_QUERY_NAME, TransformOrionViewsField } from './transforms/orionViews'
+import {
+  ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME,
+  ORION_CHANNEL_VIEWS_QUERY_NAME,
+  TransformBatchedChannelOrionViewsField,
+  TransformOrionChannelViewsField,
+} from './transforms/orionViews'
+import { RemoveQueryNodeChannelViewsField } from './transforms/queryNodeViews'
+
+import { Channel, ChannelEdge, Video, VideoEdge } from '../queries'
 
-import { VideoEdge } from '../queries'
+const BATCHED_VIDEO_VIEWS_QUERY_NAME = 'GetBatchedVideoViews'
+const BATCHED_CHANNEL_VIEWS_QUERY_NAME = 'GetBatchedChannelViews'
+const BATCHED_FOLLOWS_VIEWS_QUERY_NAME = 'GetBatchedChannelFollows'
 
 const createResolverWithTransforms = (
   schema: GraphQLSchema,
   fieldName: string,
-  transforms: Array<Transform>
+  transforms: Array<Transform>,
+  operationName?: string
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 ): ISchemaLevelResolver<any, any> => {
   return async (parent, args, context, info) =>
     delegateToSchema({
       schema,
       operation: 'query',
-      operationName: info?.operation?.name?.value,
+      operationName: operationName ? operationName : info?.operation?.name?.value,
       fieldName,
       args,
       context,
@@ -45,15 +60,96 @@ export const queryNodeStitchingResolvers = (
     videoByUniqueInput: createResolverWithTransforms(queryNodeSchema, 'videoByUniqueInput', [
       RemoveQueryNodeViewsField,
     ]),
-    videos: createResolverWithTransforms(queryNodeSchema, 'videos', [RemoveQueryNodeViewsField]),
+    videos: async (parent, args, context, info) => {
+      try {
+        const videosResolver = createResolverWithTransforms(queryNodeSchema, 'videos', [RemoveQueryNodeViewsField])
+        const videos = await videosResolver(parent, args, context, info)
+
+        const batchedVideoViewsResolver = createResolverWithTransforms(
+          orionSchema,
+          ORION_BATCHED_VIEWS_QUERY_NAME,
+          [TransformBatchedOrionViewsField],
+          // operationName has to be manually kept in sync with the query name used
+          BATCHED_VIDEO_VIEWS_QUERY_NAME
+        )
+        const batchedVideoViews = await batchedVideoViewsResolver(
+          parent,
+          {
+            videoIdList: videos.map((video: Video) => video.id),
+          },
+          context,
+          info
+        )
+
+        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 })
+        return null
+      }
+    },
     videosConnection: createResolverWithTransforms(queryNodeSchema, 'videosConnection', [RemoveQueryNodeViewsField]),
     // channel queries
     channelByUniqueInput: createResolverWithTransforms(queryNodeSchema, 'channelByUniqueInput', [
       RemoveQueryNodeFollowsField,
+      RemoveQueryNodeChannelViewsField,
     ]),
-    channels: createResolverWithTransforms(queryNodeSchema, 'channels', [RemoveQueryNodeFollowsField]),
+    channels: async (parent, args, context, info) => {
+      try {
+        const channelsResolver = createResolverWithTransforms(queryNodeSchema, 'channels', [
+          RemoveQueryNodeFollowsField,
+          RemoveQueryNodeChannelViewsField,
+        ])
+        const channels = await channelsResolver(parent, args, context, info)
+
+        const batchedChannelFollowsResolver = createResolverWithTransforms(
+          orionSchema,
+          ORION_BATCHED_FOLLOWS_QUERY_NAME,
+          [TransformBatchedOrionFollowsField],
+          // operationName has to be manually kept in sync with the query name used
+          BATCHED_FOLLOWS_VIEWS_QUERY_NAME
+        )
+        const batchedChannelFollows = await batchedChannelFollowsResolver(
+          parent,
+          {
+            channelIdList: channels.map((channel: Channel) => channel.id),
+          },
+          context,
+          info
+        )
+        const followsLookup = createLookup<{ id: string; follows: number }>(batchedChannelFollows || [])
+
+        const batchedChannelViewsResolver = createResolverWithTransforms(
+          orionSchema,
+          ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME,
+          [TransformBatchedChannelOrionViewsField],
+          // operationName has to be manually kept in sync with the query name used
+          BATCHED_CHANNEL_VIEWS_QUERY_NAME
+        )
+        const batchedChannelViews = await batchedChannelViewsResolver(
+          parent,
+          {
+            channelIdList: channels.map((channel: Channel) => channel.id),
+          },
+          context,
+          info
+        )
+
+        const viewsLookup = createLookup<{ id: string; views: number }>(batchedChannelViews || [])
+
+        return channels.map((channel: Channel) => ({
+          ...channel,
+          follows: followsLookup[channel.id]?.follows || 0,
+          views: viewsLookup[channel.id]?.views || 0,
+        }))
+      } catch (error) {
+        Logger.warn('Failed to resolve channels field', { error })
+        return null
+      }
+    },
     channelsConnection: createResolverWithTransforms(queryNodeSchema, 'channelsConnection', [
       RemoveQueryNodeFollowsField,
+      RemoveQueryNodeChannelViewsField,
     ]),
     // mixed queries
     search: createResolverWithTransforms(queryNodeSchema, 'search', [
@@ -63,23 +159,25 @@ export const queryNodeStitchingResolvers = (
   },
   Video: {
     views: async (parent, args, context, info) => {
-      if (parent.views != null) {
+      if (parent.views !== undefined) {
         return parent.views
       }
+      const orionViewsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_VIEWS_QUERY_NAME,
+        [TransformOrionViewsField],
+        // operationName has to be manually kept in sync with the query name used
+        'GetVideoViews'
+      )
       try {
-        return await delegateToSchema({
-          schema: orionSchema,
-          operation: 'query',
-          // operationName has to be manually kept in sync with the query name used
-          operationName: 'GetVideoViews',
-          fieldName: ORION_VIEWS_QUERY_NAME,
-          args: {
+        return await orionViewsResolver(
+          parent,
+          {
             videoId: parent.id,
           },
           context,
-          info,
-          transforms: [TransformOrionViewsField],
-        })
+          info
+        )
       } catch (error) {
         Logger.warn('Failed to resolve views field', { error })
         return null
@@ -88,19 +186,21 @@ export const queryNodeStitchingResolvers = (
   },
   VideoConnection: {
     edges: async (parent, args, context, info) => {
-      const batchedVideoViews = await delegateToSchema({
-        schema: orionSchema,
-        operation: 'query',
+      const batchedVideoViewsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_BATCHED_VIEWS_QUERY_NAME,
+        [TransformBatchedOrionViewsField],
         // operationName has to be manually kept in sync with the query name used
-        operationName: 'GetBatchedVideoViews',
-        fieldName: ORION_BATCHED_VIEWS_QUERY_NAME,
-        args: {
+        BATCHED_VIDEO_VIEWS_QUERY_NAME
+      )
+      const batchedVideoViews = await batchedVideoViewsResolver(
+        parent,
+        {
           videoIdList: parent.edges.map((edge: VideoEdge) => edge.node.id),
         },
         context,
-        info,
-        transforms: [TransformBatchedOrionViewsField],
-      })
+        info
+      )
 
       const viewsLookup = createLookup<{ id: string; views: number }>(batchedVideoViews || [])
 
@@ -113,27 +213,102 @@ export const queryNodeStitchingResolvers = (
       }))
     },
   },
-
   Channel: {
+    views: async (parent, args, context, info) => {
+      if (parent.views != null) {
+        return parent.views
+      }
+      const orionViewsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_CHANNEL_VIEWS_QUERY_NAME,
+        [TransformOrionChannelViewsField],
+        // operationName has to be manually kept in sync with the query name used
+        'GetChannelViews'
+      )
+      try {
+        return await orionViewsResolver(
+          parent,
+          {
+            channelId: parent.id,
+          },
+          context,
+          info
+        )
+      } catch (error) {
+        Logger.warn('Failed to resolve views field', { error })
+        return null
+      }
+    },
     follows: async (parent, args, context, info) => {
+      if (parent.follows !== undefined) {
+        return parent.follows
+      }
+      const orionFollowsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_FOLLOWS_QUERY_NAME,
+        [TransformOrionFollowsField],
+        'GetChannelFollows'
+      )
       try {
-        return await delegateToSchema({
-          schema: orionSchema,
-          operation: 'query',
-          // operationName has to be manually kept in sync with the query name used
-          operationName: 'GetChannelFollows',
-          fieldName: ORION_FOLLOWS_QUERY_NAME,
-          args: {
+        return await orionFollowsResolver(
+          parent,
+          {
             channelId: parent.id,
           },
           context,
-          info,
-          transforms: [TransformOrionFollowsField],
-        })
+          info
+        )
       } catch (error) {
         Logger.warn('Failed to resolve follows field', { error })
         return null
       }
     },
   },
+  ChannelConnection: {
+    edges: async (parent, args, context, info) => {
+      const batchedChannelFollowsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_BATCHED_FOLLOWS_QUERY_NAME,
+        [TransformBatchedOrionFollowsField],
+        // operationName has to be manually kept in sync with the query name used
+        BATCHED_FOLLOWS_VIEWS_QUERY_NAME
+      )
+      const batchedChannelFollows = await batchedChannelFollowsResolver(
+        parent,
+        {
+          channelIdList: parent.edges.map((edge: ChannelEdge) => edge.node.id),
+        },
+        context,
+        info
+      )
+
+      const batchedChannelViewsResolver = createResolverWithTransforms(
+        orionSchema,
+        ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME,
+        [TransformBatchedChannelOrionViewsField],
+        // operationName has to be manually kept in sync with the query name used
+        BATCHED_CHANNEL_VIEWS_QUERY_NAME
+      )
+      const batchedChannelViews = await batchedChannelViewsResolver(
+        parent,
+        {
+          channelIdList: parent.edges.map((edge: ChannelEdge) => edge.node.id),
+        },
+        context,
+        info
+      )
+
+      const followsLookup = createLookup<{ id: string; follows: number }>(batchedChannelFollows || [])
+      const viewsLookup = createLookup<{ id: string; views: number }>(batchedChannelViews || [])
+
+      return parent.edges.map((edge: ChannelEdge) => ({
+        ...edge,
+        node: {
+          ...edge.node,
+          follows: followsLookup[edge.node.id]?.follows || 0,
+          views: viewsLookup[edge.node.id]?.views || 0,
+        },
+      }))
+    },
+  },
 })

+ 16 - 2
src/api/client/transforms/index.ts

@@ -1,4 +1,18 @@
-export { ORION_FOLLOWS_QUERY_NAME, TransformOrionFollowsField } from './orionFollows'
-export { ORION_BATCHED_VIEWS_QUERY_NAME, TransformBatchedOrionViewsField } from './orionViews'
+export {
+  ORION_BATCHED_FOLLOWS_QUERY_NAME,
+  ORION_FOLLOWS_QUERY_NAME,
+  TransformBatchedOrionFollowsField,
+  TransformOrionFollowsField,
+} from './orionFollows'
+export {
+  ORION_BATCHED_VIEWS_QUERY_NAME,
+  ORION_VIEWS_QUERY_NAME,
+  TransformBatchedOrionViewsField,
+  TransformOrionViewsField,
+  ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME,
+  ORION_CHANNEL_VIEWS_QUERY_NAME,
+  TransformBatchedChannelOrionViewsField,
+  TransformOrionChannelViewsField,
+} from './orionViews'
 export { RemoveQueryNodeFollowsField } from './queryNodeFollows'
 export { RemoveQueryNodeViewsField } from './queryNodeViews'

+ 38 - 0
src/api/client/transforms/orionFollows.ts

@@ -74,3 +74,41 @@ export const TransformOrionFollowsField: Transform = {
     return { data }
   },
 }
+
+export const ORION_BATCHED_FOLLOWS_QUERY_NAME = 'batchedChannelFollows'
+
+export const TransformBatchedOrionFollowsField: Transform = {
+  transformRequest(request) {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'OperationDefinition') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.map((selection) => {
+                if (selection.kind === 'Field' && selection.name.value === ORION_BATCHED_FOLLOWS_QUERY_NAME) {
+                  return {
+                    ...selection,
+                    selectionSet: CHANNEL_INFO_SELECTION_SET,
+                  }
+                }
+                return selection
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+
+    return request
+  },
+  transformResult(result) {
+    if (result.errors) {
+      throw new OrionError(result.errors)
+    }
+    return result
+  },
+}

+ 83 - 3
src/api/client/transforms/orionViews.ts

@@ -10,7 +10,7 @@ class OrionError extends Error {
   }
 }
 
-const VIDEO_INFO_SELECTION_SET: SelectionSetNode = {
+const INFO_SELECTION_SET: SelectionSetNode = {
   kind: 'SelectionSet',
   selections: [
     {
@@ -47,7 +47,7 @@ export const TransformOrionViewsField: Transform = {
                 if (selection.kind === 'Field' && selection.name.value === ORION_VIEWS_QUERY_NAME) {
                   return {
                     ...selection,
-                    selectionSet: VIDEO_INFO_SELECTION_SET,
+                    selectionSet: INFO_SELECTION_SET,
                   }
                 }
                 return selection
@@ -91,7 +91,87 @@ export const TransformBatchedOrionViewsField: Transform = {
                 if (selection.kind === 'Field' && selection.name.value === ORION_BATCHED_VIEWS_QUERY_NAME) {
                   return {
                     ...selection,
-                    selectionSet: VIDEO_INFO_SELECTION_SET,
+                    selectionSet: INFO_SELECTION_SET,
+                  }
+                }
+                return selection
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+
+    return request
+  },
+  transformResult(result) {
+    if (result.errors) {
+      throw new OrionError(result.errors)
+    }
+    return result
+  },
+}
+
+export const ORION_CHANNEL_VIEWS_QUERY_NAME = 'channelViews'
+export const TransformOrionChannelViewsField: Transform = {
+  transformRequest(request) {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'OperationDefinition') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.map((selection) => {
+                if (selection.kind === 'Field' && selection.name.value === ORION_CHANNEL_VIEWS_QUERY_NAME) {
+                  return {
+                    ...selection,
+                    selectionSet: INFO_SELECTION_SET,
+                  }
+                }
+                return selection
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+
+    return request
+  },
+  transformResult(result) {
+    if (result.errors) {
+      throw new OrionError(result.errors)
+    }
+
+    const views = result?.data?.[ORION_CHANNEL_VIEWS_QUERY_NAME]?.views || 0
+    const data = {
+      channelViews: views,
+    }
+    return { data }
+  },
+}
+
+export const ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME = 'batchedChannelsViews'
+
+export const TransformBatchedChannelOrionViewsField: Transform = {
+  transformRequest(request) {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'OperationDefinition') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.map((selection) => {
+                if (selection.kind === 'Field' && selection.name.value === ORION_BATCHED_CHANNEL_VIEWS_QUERY_NAME) {
+                  return {
+                    ...selection,
+                    selectionSet: INFO_SELECTION_SET,
                   }
                 }
                 return selection

+ 24 - 0
src/api/client/transforms/queryNodeViews.ts

@@ -23,3 +23,27 @@ export const RemoveQueryNodeViewsField: Transform = {
     return request
   },
 }
+
+// remove views field from the query node video request
+export const RemoveQueryNodeChannelViewsField: Transform = {
+  transformRequest: (request) => {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'FragmentDefinition' && definition.name.value === 'AllChannelFields') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.filter((selection) => {
+                return selection.kind !== 'Field' || selection.name.value !== 'views'
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+    return request
+  },
+}

+ 2 - 1
src/api/hooks/channel.ts

@@ -42,7 +42,7 @@ export const useChannel = (id: string, opts?: ChannelOpts) => {
 }
 
 type VideoCountOpts = QueryHookOptions<GetVideoCountQuery>
-export const useChannelVideoCount = (channelId: string, opts?: VideoCountOpts) => {
+export const useChannelVideoCount = (channelId: string, createdAt_gte?: Date, opts?: VideoCountOpts) => {
   const { data, ...rest } = useGetVideoCountQuery({
     ...opts,
     variables: {
@@ -52,6 +52,7 @@ export const useChannelVideoCount = (channelId: string, opts?: VideoCountOpts) =
         mediaAvailability_eq: AssetAvailability.Accepted,
         isPublic_eq: true,
         isCensored_eq: false,
+        createdAt_gte: createdAt_gte,
       },
     },
   })

+ 3 - 0
src/api/queries/__generated__/baseTypes.generated.ts

@@ -115,6 +115,7 @@ export type Channel = {
   avatarPhotoUrls: Array<Scalars['String']>
   avatarPhotoAvailability: AssetAvailability
   follows?: Maybe<Scalars['Int']>
+  views?: Maybe<Scalars['Int']>
 }
 
 export type ChannelWhereInput = {
@@ -311,6 +312,8 @@ export type QueryChannelViewsArgs = {
 }
 
 export type QueryChannelsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
   where?: Maybe<ChannelWhereInput>
 }
 

+ 173 - 2
src/api/queries/__generated__/channels.generated.tsx

@@ -19,11 +19,13 @@ export type AllChannelFieldsFragment = {
   __typename?: 'Channel'
   description?: Types.Maybe<string>
   follows?: Types.Maybe<number>
+  views?: Types.Maybe<number>
   isPublic?: Types.Maybe<boolean>
   isCensored: boolean
   coverPhotoUrls: Array<string>
   coverPhotoAvailability: Types.AssetAvailability
   language?: Types.Maybe<{ __typename?: 'Language'; iso: string }>
+  ownerMember?: Types.Maybe<{ __typename?: 'Membership'; id: string; handle: string; avatarUri?: Types.Maybe<string> }>
   coverPhotoDataObject?: Types.Maybe<{ __typename?: 'DataObject' } & DataObjectFieldsFragment>
 } & BasicChannelFieldsFragment
 
@@ -55,6 +57,8 @@ 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>
 }>
 
@@ -92,6 +96,33 @@ export type GetChannelFollowsQuery = {
   channelFollows?: Types.Maybe<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>
 }
 
+export type GetBatchedChannelFollowsQueryVariables = Types.Exact<{
+  channelIdList: Array<Types.Scalars['ID']> | Types.Scalars['ID']
+}>
+
+export type GetBatchedChannelFollowsQuery = {
+  __typename?: 'Query'
+  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']
+}>
+
+export type GetBatchedChannelViewsQuery = {
+  __typename?: 'Query'
+  batchedChannelsViews: Array<Types.Maybe<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
+}
+
 export type FollowChannelMutationVariables = Types.Exact<{
   channelId: Types.Scalars['ID']
 }>
@@ -128,11 +159,17 @@ export const AllChannelFieldsFragmentDoc = gql`
     ...BasicChannelFields
     description
     follows
+    views
     isPublic
     isCensored
     language {
       iso
     }
+    ownerMember {
+      id
+      handle
+      avatarUri
+    }
     coverPhotoUrls
     coverPhotoAvailability
     coverPhotoDataObject {
@@ -254,8 +291,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($where: ChannelWhereInput) {
-    channels(where: $where) {
+  query GetChannels($offset: Int, $limit: Int, $where: ChannelWhereInput) {
+    channels(offset: $offset, limit: $limit, where: $where) {
       ...AllChannelFields
     }
   }
@@ -274,6 +311,8 @@ export const GetChannelsDocument = gql`
  * @example
  * const { data, loading, error } = useGetChannelsQuery({
  *   variables: {
+ *      offset: // value for 'offset'
+ *      limit: // value for 'limit'
  *      where: // value for 'where'
  *   },
  * });
@@ -394,6 +433,138 @@ export function useGetChannelFollowsLazyQuery(
 export type GetChannelFollowsQueryHookResult = ReturnType<typeof useGetChannelFollowsQuery>
 export type GetChannelFollowsLazyQueryHookResult = ReturnType<typeof useGetChannelFollowsLazyQuery>
 export type GetChannelFollowsQueryResult = Apollo.QueryResult<GetChannelFollowsQuery, GetChannelFollowsQueryVariables>
+export const GetBatchedChannelFollowsDocument = gql`
+  query GetBatchedChannelFollows($channelIdList: [ID!]!) {
+    batchedChannelFollows(channelIdList: $channelIdList) {
+      id
+      follows
+    }
+  }
+`
+
+/**
+ * __useGetBatchedChannelFollowsQuery__
+ *
+ * To run a query within a React component, call `useGetBatchedChannelFollowsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetBatchedChannelFollowsQuery` 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 } = useGetBatchedChannelFollowsQuery({
+ *   variables: {
+ *      channelIdList: // value for 'channelIdList'
+ *   },
+ * });
+ */
+export function useGetBatchedChannelFollowsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetBatchedChannelFollowsQuery, GetBatchedChannelFollowsQueryVariables>
+) {
+  return Apollo.useQuery<GetBatchedChannelFollowsQuery, GetBatchedChannelFollowsQueryVariables>(
+    GetBatchedChannelFollowsDocument,
+    baseOptions
+  )
+}
+export function useGetBatchedChannelFollowsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetBatchedChannelFollowsQuery, GetBatchedChannelFollowsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetBatchedChannelFollowsQuery, GetBatchedChannelFollowsQueryVariables>(
+    GetBatchedChannelFollowsDocument,
+    baseOptions
+  )
+}
+export type GetBatchedChannelFollowsQueryHookResult = ReturnType<typeof useGetBatchedChannelFollowsQuery>
+export type GetBatchedChannelFollowsLazyQueryHookResult = ReturnType<typeof useGetBatchedChannelFollowsLazyQuery>
+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) {
+      id
+      views
+    }
+  }
+`
+
+/**
+ * __useGetBatchedChannelViewsQuery__
+ *
+ * To run a query within a React component, call `useGetBatchedChannelViewsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetBatchedChannelViewsQuery` 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 } = useGetBatchedChannelViewsQuery({
+ *   variables: {
+ *      channelIdList: // value for 'channelIdList'
+ *   },
+ * });
+ */
+export function useGetBatchedChannelViewsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetBatchedChannelViewsQuery, GetBatchedChannelViewsQueryVariables>
+) {
+  return Apollo.useQuery<GetBatchedChannelViewsQuery, GetBatchedChannelViewsQueryVariables>(
+    GetBatchedChannelViewsDocument,
+    baseOptions
+  )
+}
+export function useGetBatchedChannelViewsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetBatchedChannelViewsQuery, GetBatchedChannelViewsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetBatchedChannelViewsQuery, GetBatchedChannelViewsQueryVariables>(
+    GetBatchedChannelViewsDocument,
+    baseOptions
+  )
+}
+export type GetBatchedChannelViewsQueryHookResult = ReturnType<typeof useGetBatchedChannelViewsQuery>
+export type GetBatchedChannelViewsLazyQueryHookResult = ReturnType<typeof useGetBatchedChannelViewsLazyQuery>
+export type GetBatchedChannelViewsQueryResult = Apollo.QueryResult<
+  GetBatchedChannelViewsQuery,
+  GetBatchedChannelViewsQueryVariables
+>
 export const FollowChannelDocument = gql`
   mutation FollowChannel($channelId: ID!) {
     followChannel(channelId: $channelId) {

+ 4 - 2
src/api/queries/__generated__/search.generated.tsx

@@ -11,6 +11,7 @@ export type SearchQueryVariables = Types.Exact<{
   text: Types.Scalars['String']
   whereVideo?: Types.Maybe<Types.VideoWhereInput>
   whereChannel?: Types.Maybe<Types.ChannelWhereInput>
+  limit?: Types.Maybe<Types.Scalars['Int']>
 }>
 
 export type SearchQuery = {
@@ -22,8 +23,8 @@ export type SearchQuery = {
 }
 
 export const SearchDocument = gql`
-  query Search($text: String!, $whereVideo: VideoWhereInput, $whereChannel: ChannelWhereInput) {
-    search(text: $text, whereVideo: $whereVideo, whereChannel: $whereChannel) {
+  query Search($text: String!, $whereVideo: VideoWhereInput, $whereChannel: ChannelWhereInput, $limit: Int) {
+    search(text: $text, whereVideo: $whereVideo, whereChannel: $whereChannel, limit: $limit) {
       item {
         ... on Video {
           ...VideoFields
@@ -53,6 +54,7 @@ export const SearchDocument = gql`
  *      text: // value for 'text'
  *      whereVideo: // value for 'whereVideo'
  *      whereChannel: // value for 'whereChannel'
+ *      limit: // value for 'limit'
  *   },
  * });
  */

+ 29 - 3
src/api/queries/channels.graphql

@@ -14,12 +14,17 @@ fragment AllChannelFields on Channel {
   ...BasicChannelFields
   description
   follows
+  views
   isPublic
   isCensored
   language {
     iso
   }
-
+  ownerMember {
+    id
+    handle
+    avatarUri
+  }
   coverPhotoUrls
   coverPhotoAvailability
   coverPhotoDataObject {
@@ -45,8 +50,8 @@ query GetVideoCount($where: VideoWhereInput) {
   }
 }
 
-query GetChannels($where: ChannelWhereInput) {
-  channels(where: $where) {
+query GetChannels($offset: Int, $limit: Int, $where: ChannelWhereInput) {
+  channels(offset: $offset, limit: $limit, where: $where) {
     ...AllChannelFields
   }
 }
@@ -77,6 +82,27 @@ query GetChannelFollows($channelId: ID!) {
   }
 }
 
+query GetBatchedChannelFollows($channelIdList: [ID!]!) {
+  batchedChannelFollows(channelIdList: $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) {
+    id
+    views
+  }
+}
+
 mutation FollowChannel($channelId: ID!) {
   followChannel(channelId: $channelId) {
     id

+ 2 - 2
src/api/queries/search.graphql

@@ -1,5 +1,5 @@
-query Search($text: String!, $whereVideo: VideoWhereInput, $whereChannel: ChannelWhereInput) {
-  search(text: $text, whereVideo: $whereVideo, whereChannel: $whereChannel) {
+query Search($text: String!, $whereVideo: VideoWhereInput, $whereChannel: ChannelWhereInput, $limit: Int) {
+  search(text: $text, whereVideo: $whereVideo, whereChannel: $whereChannel, limit: $limit) {
     item {
       ... on Video {
         ...VideoFields

+ 2 - 1
src/api/schemas/extendedQueryNode.graphql

@@ -110,6 +110,7 @@ type Channel {
 
   # === extended from Orion ===
   follows: Int
+  views: Int
 }
 
 input ChannelWhereInput {
@@ -266,7 +267,7 @@ type Query {
   channelByUniqueInput(where: ChannelWhereUniqueInput!): Channel
 
   # List all channels by given constraints
-  channels(where: ChannelWhereInput): [Channel!]!
+  channels(offset: Int, limit: Int, where: ChannelWhereInput): [Channel!]!
 
   # List all channel by given constraints
   channelsConnection(

+ 5 - 5
src/components/ChannelPreview.tsx → src/components/ChannelCard.tsx

@@ -4,25 +4,25 @@ import { useChannel } from '@/api/hooks'
 import { useChannelVideoCount } from '@/api/hooks/channel'
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, useAsset } from '@/providers'
-import { ChannelPreviewBase } from '@/shared/components'
+import { ChannelCardBase } from '@/shared/components'
 
-type ChannelPreviewProps = {
+type ChannelCardProps = {
   id?: string
   className?: string
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
 }
 
-export const ChannelPreview: React.FC<ChannelPreviewProps> = ({ id, className, onClick }) => {
+export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick }) => {
   const { channel, loading } = useChannel(id ?? '', { fetchPolicy: 'cache-first', skip: !id })
   const { url } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
-  const { videoCount } = useChannelVideoCount(id ?? '', {
+  const { videoCount } = useChannelVideoCount(id ?? '', undefined, {
     fetchPolicy: 'cache-first',
     skip: !id,
   })
   const isLoading = loading || id === undefined
 
   return (
-    <ChannelPreviewBase
+    <ChannelCardBase
       className={className}
       title={channel?.title}
       channelHref={id ? absoluteRoutes.viewer.channel(id) : undefined}

+ 3 - 3
src/components/ChannelGallery.tsx

@@ -5,7 +5,7 @@ import { BasicChannelFieldsFragment } from '@/api/queries'
 import { Gallery } from '@/shared/components'
 import { sizes } from '@/shared/theme'
 
-import { ChannelPreview } from './ChannelPreview'
+import { ChannelCard } from './ChannelCard'
 
 type ChannelGalleryProps = {
   title?: string
@@ -27,13 +27,13 @@ export const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels
   return (
     <Gallery title={title} itemWidth={220} exactWidth={true} paddingLeft={sizes(2, true)} paddingTop={sizes(2, true)}>
       {[...channels, ...placeholderItems].map((channel, idx) => (
-        <StyledChannelPreview key={idx} id={channel.id} onClick={createClickHandler(channel.id)} />
+        <StyledChannelCard key={idx} id={channel.id} onClick={createClickHandler(channel.id)} />
       ))}
     </Gallery>
   )
 }
 
-const StyledChannelPreview = styled(ChannelPreview)`
+const StyledChannelCard = styled(ChannelCard)`
   + * {
     margin-left: 16px;
   }

+ 3 - 3
src/components/ChannelGrid.tsx

@@ -4,9 +4,9 @@ import React from 'react'
 import { BasicChannelFieldsFragment } from '@/api/queries'
 import { Grid } from '@/shared/components'
 
-import { ChannelPreview } from './ChannelPreview'
+import { ChannelCard } from './ChannelCard'
 
-const StyledChannelPreview = styled(ChannelPreview)`
+const StyledChannelCard = styled(ChannelCard)`
   margin: 0 auto;
 `
 
@@ -25,7 +25,7 @@ export const ChannelGrid: React.FC<ChannelGridProps> = ({ channels, onChannelCli
   return (
     <Grid {...gridProps}>
       {channels.map(({ id }) => (
-        <StyledChannelPreview key={id} id={id} onClick={() => handleClick(id)} />
+        <StyledChannelCard key={id} id={id} onClick={() => handleClick(id)} />
       ))}
     </Grid>
   )

+ 2 - 2
src/components/ChannelLink/ChannelLink.style.ts

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled'
 import { Link } from 'react-router-dom'
 
-import { Placeholder } from '@/shared/components'
+import { SkeletonLoader } from '@/shared/components'
 import { colors, sizes, typography } from '@/shared/theme'
 
 type ContainerProps = {
@@ -29,6 +29,6 @@ export const Handle = styled.span<HandleProps>`
   margin-left: ${({ withAvatar }) => (withAvatar ? sizes(2) : 0)};
 `
 
-export const HandlePlaceholder = styled(Placeholder)<HandleProps>`
+export const HandleSkeletonLoader = styled(SkeletonLoader)<HandleProps>`
   margin-left: ${({ withAvatar }) => (withAvatar ? sizes(2) : 0)};
 `

+ 2 - 2
src/components/ChannelLink/ChannelLink.tsx

@@ -7,7 +7,7 @@ import { AssetType, useAsset } from '@/providers'
 import { Avatar, AvatarSize } from '@/shared/components/Avatar'
 import { Logger } from '@/utils/logger'
 
-import { Container, Handle, HandlePlaceholder } from './ChannelLink.style'
+import { Container, Handle, HandleSkeletonLoader } from './ChannelLink.style'
 
 type ChannelLinkProps = {
   id?: string
@@ -50,7 +50,7 @@ export const ChannelLink: React.FC<ChannelLinkProps> = ({
         (displayedChannel ? (
           <Handle withAvatar={!hideAvatar}>{displayedChannel.title}</Handle>
         ) : (
-          <HandlePlaceholder withAvatar={!hideAvatar} height={16} width={150} />
+          <HandleSkeletonLoader withAvatar={!hideAvatar} height={16} width={150} />
         ))}
     </Container>
   )

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

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

+ 40 - 7
src/components/Dialogs/ActionDialog/ActionDialog.stories.tsx

@@ -14,12 +14,14 @@ export default {
   },
   argTypes: {
     exitButton: { defaultValue: true },
-    primaryButtonText: { defaultValue: 'hello darkness' },
-    secondaryButtonText: { defaultValue: 'my old friend' },
+    primaryButtonText: { defaultValue: 'hello darkness', type: { name: 'string', required: false } },
+    secondaryButtonText: { defaultValue: 'my old friend', type: { name: 'string', required: false } },
     showDialog: { table: { disable: true } },
     additionalActionsNode: { table: { disable: true } },
     warning: { defaultValue: false },
     error: { defaultValue: false },
+    primaryButton: { table: { disable: true } },
+    secondaryButton: { table: { disable: true } },
   },
   decorators: [
     (Story) => (
@@ -32,6 +34,8 @@ export default {
 
 type StoryProps = ActionDialogProps & {
   showAdditionalAction?: boolean
+  primaryButtonText?: string
+  secondaryButtonText?: string
 }
 
 const additionalActionNode = (
@@ -47,23 +51,50 @@ const content = (
   </div>
 )
 
-const RegularTemplate: Story<StoryProps> = ({ showAdditionalAction, ...args }) => {
+const RegularTemplate: Story<StoryProps> = ({
+  showAdditionalAction,
+  primaryButtonText,
+  secondaryButtonText,
+  ...args
+}) => {
   return (
-    <ActionDialog {...args} showDialog={true} additionalActionsNode={showAdditionalAction && additionalActionNode} />
+    <ActionDialog
+      {...args}
+      primaryButton={{ text: primaryButtonText }}
+      secondaryButton={{ text: secondaryButtonText }}
+      showDialog={true}
+      additionalActionsNode={showAdditionalAction && additionalActionNode}
+    />
   )
 }
 export const Regular = RegularTemplate.bind({})
 
-const ContentTemplate: Story<StoryProps> = ({ showAdditionalAction, ...args }) => {
+const ContentTemplate: Story<StoryProps> = ({
+  showAdditionalAction,
+  primaryButtonText,
+  secondaryButtonText,
+  ...args
+}) => {
   return (
-    <ActionDialog {...args} showDialog={true} additionalActionsNode={showAdditionalAction && additionalActionNode}>
+    <ActionDialog
+      {...args}
+      primaryButton={{ text: primaryButtonText }}
+      secondaryButton={{ text: secondaryButtonText }}
+      showDialog={true}
+      additionalActionsNode={showAdditionalAction && additionalActionNode}
+    >
       {content}
     </ActionDialog>
   )
 }
 export const WithContent = ContentTemplate.bind({})
 
-const TransitionTemplate: Story<StoryProps> = ({ showAdditionalAction, ...args }) => {
+const TransitionTemplate: Story<StoryProps> = ({
+  showAdditionalAction,
+  primaryButtonText,
+  secondaryButtonText,
+  ...args
+}) => {
   const [showDialog, setShowDialog] = useState(false)
 
   return (
@@ -71,6 +102,8 @@ const TransitionTemplate: Story<StoryProps> = ({ showAdditionalAction, ...args }
       <Button onClick={() => setShowDialog(true)}>Open Dialog</Button>
       <ActionDialog
         {...args}
+        primaryButton={{ text: primaryButtonText }}
+        secondaryButton={{ text: secondaryButtonText }}
         onExitClick={() => setShowDialog(false)}
         showDialog={showDialog}
         additionalActionsNode={showAdditionalAction && additionalActionNode}

+ 0 - 62
src/components/Dialogs/ActionDialog/ActionDialog.style.ts

@@ -1,14 +1,7 @@
-import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 
-import { Button } from '@/shared/components'
 import { media, sizes } from '@/shared/theme'
 
-type ButtonProps = {
-  error?: boolean
-  warning?: boolean
-}
-
 export const ButtonsContainer = styled.div`
   display: flex;
   flex-direction: column;
@@ -52,58 +45,3 @@ export const AdditionalActionsContainer = styled.div`
     margin-right: ${sizes(6)};
   }
 `
-
-const buttonColorsFromProps = ({ error, warning }: ButtonProps) => {
-  let color, bgColor, borderColor, bgActiveColor, borderActiveColor
-
-  if (warning) {
-    color = 'var(--warning-font-color)'
-    bgColor = 'var(--warning-bg-color)'
-    borderColor = 'var(--warning-bg-color)'
-    bgActiveColor = 'var(--warning-bg-active-color)'
-    borderActiveColor = 'var(--warning-border-active-color)'
-  }
-  if (error) {
-    color = 'var(--error-font-color)'
-    bgColor = 'var(--error-bg-color)'
-    borderColor = 'var(--error-bg-color)'
-    bgActiveColor = 'var(--error-bg-active-color)'
-    borderActiveColor = 'var(--error-border-active-color)'
-  }
-
-  const boxShadow = error || warning ? `inset 0px 0px 0px 1px ${borderActiveColor}` : 'none'
-
-  return css`
-    color: ${color};
-    background-color: ${bgColor};
-    border-color: ${borderColor};
-
-    &:hover {
-      color: ${color};
-      background-color: ${bgColor};
-      border-color: ${borderColor};
-      box-shadow: none;
-    }
-
-    &:active {
-      color: ${color};
-      background-color: ${bgActiveColor};
-      border-color: ${borderActiveColor};
-      box-shadow: ${boxShadow};
-    }
-  `
-}
-
-export const StyledPrimaryButton = styled(Button)<ButtonProps>`
-  --warning-bg-color: #f49525;
-  --warning-bg-active-color: #da7b0b;
-  --warning-border-active-color: #49290440;
-  --warning-font-color: #492904;
-
-  --error-bg-color: #e53333;
-  --error-bg-active-color: #cc1a1a;
-  --error-border-active-color: #44090966;
-  --error-font-color: #440909;
-
-  ${buttonColorsFromProps}
-`

+ 20 - 32
src/components/Dialogs/ActionDialog/ActionDialog.tsx

@@ -1,42 +1,35 @@
 import React from 'react'
 
-import { Button } from '@/shared/components'
+import { Button, ButtonProps } from '@/shared/components'
 
-import {
-  ActionsContainer,
-  AdditionalActionsContainer,
-  ButtonsContainer,
-  StyledPrimaryButton,
-} from './ActionDialog.style'
+import { ActionsContainer, AdditionalActionsContainer, ButtonsContainer } from './ActionDialog.style'
 
 import { BaseDialog, BaseDialogProps } from '../BaseDialog'
 
+type DialogButtonProps = {
+  text?: string
+  disabled?: boolean
+  onClick?: (e: React.MouseEvent) => void
+} & Omit<ButtonProps, 'children'>
+
 export type ActionDialogProps = {
   additionalActionsNode?: React.ReactNode
-  primaryButtonText?: string
-  secondaryButtonText?: string
-  primaryButtonDisabled?: boolean
-  secondaryButtonDisabled?: boolean
-  onPrimaryButtonClick?: (e: React.MouseEvent) => void
-  onSecondaryButtonClick?: (e: React.MouseEvent) => void
+  primaryButton?: DialogButtonProps
+  secondaryButton?: DialogButtonProps
   warning?: boolean
   error?: boolean
 } & BaseDialogProps
 
 export const ActionDialog: React.FC<ActionDialogProps> = ({
   additionalActionsNode,
-  primaryButtonText,
-  secondaryButtonText,
-  primaryButtonDisabled,
-  secondaryButtonDisabled,
-  onPrimaryButtonClick,
-  onSecondaryButtonClick,
+  primaryButton,
+  secondaryButton,
   warning,
   error,
   children,
   ...baseDialogProps
 }) => {
-  const hasAnyAction = additionalActionsNode || primaryButtonText || secondaryButtonText
+  const hasAnyAction = additionalActionsNode || primaryButton?.text || secondaryButton?.text
 
   return (
     <BaseDialog {...baseDialogProps}>
@@ -45,19 +38,14 @@ export const ActionDialog: React.FC<ActionDialogProps> = ({
         <ActionsContainer>
           {additionalActionsNode && <AdditionalActionsContainer>{additionalActionsNode}</AdditionalActionsContainer>}
           <ButtonsContainer>
-            {primaryButtonText && (
-              <StyledPrimaryButton
-                onClick={onPrimaryButtonClick}
-                warning={warning}
-                error={error}
-                disabled={primaryButtonDisabled}
-              >
-                {primaryButtonText}
-              </StyledPrimaryButton>
+            {primaryButton?.text && (
+              <Button variant={error ? 'destructive' : warning ? 'warning' : 'primary'} {...primaryButton}>
+                {primaryButton.text}
+              </Button>
             )}
-            {secondaryButtonText && (
-              <Button variant="secondary" onClick={onSecondaryButtonClick} disabled={secondaryButtonDisabled}>
-                {secondaryButtonText}
+            {secondaryButton?.text && (
+              <Button variant="secondary" {...secondaryButton}>
+                {secondaryButton.text}
               </Button>
             )}
           </ButtonsContainer>

+ 1 - 0
src/components/Dialogs/BaseDialog/BaseDialog.style.ts

@@ -15,6 +15,7 @@ export const DialogBackDrop = styled.div`
 `
 
 export const StyledContainer = styled.div`
+  display: grid;
   --dialog-padding: ${sizes(4)};
   ${media.small} {
     --dialog-padding: ${sizes(6)};

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

@@ -3,7 +3,7 @@ import { Meta, Story } from '@storybook/react'
 import React, { useRef, useState } from 'react'
 
 import { OverlayManagerProvider } from '@/providers'
-import { Avatar, Placeholder } from '@/shared/components'
+import { Avatar, SkeletonLoader } from '@/shared/components'
 import { AssetDimensions, ImageCropData } from '@/types/cropper'
 
 import { ImageCropDialog, ImageCropDialogImperativeHandle, ImageCropDialogProps } from './ImageCropDialog'
@@ -66,12 +66,12 @@ const RegularTemplate: Story<ImageCropDialogProps> = () => {
       {thumbnailImageUrl ? (
         <Image src={thumbnailImageUrl} onClick={() => thumbnailDialogRef.current?.open()} />
       ) : (
-        <ImagePlaceholder onClick={() => thumbnailDialogRef.current?.open()} />
+        <ImageSkeletonLoader onClick={() => thumbnailDialogRef.current?.open()} />
       )}
       {coverImageUrl ? (
         <Image src={coverImageUrl} onClick={() => coverDialogRef.current?.open()} />
       ) : (
-        <ImagePlaceholder onClick={() => coverDialogRef.current?.open()} />
+        <ImageSkeletonLoader onClick={() => coverDialogRef.current?.open()} />
       )}
 
       <ImageCropDialog imageType="avatar" onConfirm={handleAvatarConfirm} ref={avatarDialogRef} />
@@ -82,7 +82,7 @@ const RegularTemplate: Story<ImageCropDialogProps> = () => {
 }
 export const Regular = RegularTemplate.bind({})
 
-const ImagePlaceholder = styled(Placeholder)`
+const ImageSkeletonLoader = styled(SkeletonLoader)`
   width: 600px;
   min-height: 200px;
   cursor: pointer;

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

@@ -1,7 +1,7 @@
 import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 
-import { Placeholder, Text } from '@/shared/components'
+import { SkeletonLoader, Text } from '@/shared/components'
 import { Slider } from '@/shared/components/Slider'
 import { colors, sizes } from '@/shared/theme'
 
@@ -51,7 +51,7 @@ const cropAreaSizeCss = css`
   height: 256px;
 `
 
-export const CropPlaceholder = styled(Placeholder)`
+export const CropSkeletonLoader = styled(SkeletonLoader)`
   ${cropAreaSizeCss};
 `
 

+ 1 - 2
src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx

@@ -139,8 +139,7 @@ const ImageCropDialogComponent: React.ForwardRefRenderFunction<
       <HiddenInput type="file" accept="image/*" onChange={handleFileChange} ref={inputRef} />
       <StyledActionDialog
         showDialog={showDialog && !!editedImageHref}
-        primaryButtonText="Confirm"
-        onPrimaryButtonClick={handleConfirmClick}
+        primaryButton={{ text: 'Confirm', onClick: handleConfirmClick }}
         onExitClick={resetDialog}
         additionalActionsNode={zoomControlNode}
       >

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

@@ -91,8 +91,7 @@ export const useCropper = ({ imageEl, imageType, cropData }: UseCropperOpts) =>
 
       setZoomRange([minZoom, maxZoom])
 
-      const middleZoom = minZoom + (maxZoom - minZoom) / 2
-      cropper.zoomTo(middleZoom)
+      cropper.zoomTo(minZoom)
 
       if (cropData) {
         const { data, canvasData, cropBoxData, zoom } = cropData

+ 1 - 3
src/components/Dialogs/TransactionDialog/TransactionDialog.tsx

@@ -53,9 +53,7 @@ export const TransactionDialog: React.FC<TransactionDialogProps> = ({ status, on
   return (
     <ActionDialog
       showDialog={!!stepDetails}
-      onSecondaryButtonClick={onClose}
-      secondaryButtonText="Cancel"
-      secondaryButtonDisabled={!canCancel}
+      secondaryButton={{ text: 'Cancel', onClick: onClose, disabled: !canCancel }}
       exitButton={false}
       {...actionDialogProps}
     >

+ 3 - 3
src/components/InfiniteGrids/InfiniteChannelGrid.tsx

@@ -12,7 +12,7 @@ import { sizes } from '@/shared/theme'
 
 import { useInfiniteGrid } from './useInfiniteGrid'
 
-import { ChannelPreview } from '../ChannelPreview'
+import { ChannelCard } from '../ChannelCard'
 
 type InfiniteChannelGridProps = {
   title?: string
@@ -67,7 +67,7 @@ export const InfiniteChannelGrid: React.FC<InfiniteChannelGridProps> = ({
     <>
       {/* we are reusing the components below by giving them the same keys */}
       {[...displayedItems, ...placeholderItems].map((channel, idx) => (
-        <StyledChannelPreview key={idx} id={channel.id} />
+        <StyledChannelCard key={idx} id={channel.id} />
       ))}
     </>
   )
@@ -94,6 +94,6 @@ const previewCss = css`
   margin: 0 auto;
 `
 
-const StyledChannelPreview = styled(ChannelPreview)`
+const StyledChannelCard = styled(ChannelCard)`
   ${previewCss};
 `

+ 5 - 5
src/components/InfiniteGrids/InfiniteVideoGrid.tsx

@@ -8,12 +8,12 @@ import {
   GetVideosConnectionQueryVariables,
   VideoWhereInput,
 } from '@/api/queries'
-import { Grid, Placeholder, Text } from '@/shared/components'
+import { Grid, SkeletonLoader, Text } from '@/shared/components'
 import { sizes } from '@/shared/theme'
 
 import { useInfiniteGrid } from './useInfiniteGrid'
 
-import { VideoPreview } from '../VideoPreview'
+import { VideoTile } from '../VideoTile'
 
 type InfiniteVideoGridProps = {
   title?: string
@@ -141,7 +141,7 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
   const gridContent = (
     <>
       {[...displayedItems, ...placeholderItems]?.map((video, idx) => (
-        <VideoPreview id={video.id} key={idx} showChannel={showChannel} />
+        <VideoTile id={video.id} key={idx} showChannel={showChannel} />
       ))}
     </>
   )
@@ -154,7 +154,7 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
   // 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 ? <StyledPlaceholder height={23} width={250} /> : <Title variant="h5">{title}</Title>)}
+      {title && (!ready ? <StyledSkeletonLoader height={23} width={250} /> : <Title variant="h5">{title}</Title>)}
       <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
     </section>
   )
@@ -164,6 +164,6 @@ const Title = styled(Text)`
   margin-bottom: ${sizes(4)};
 `
 
-const StyledPlaceholder = styled(Placeholder)`
+const StyledSkeletonLoader = styled(SkeletonLoader)`
   margin-bottom: ${sizes(4)};
 `

+ 3 - 3
src/components/StudioContainer.tsx → src/components/LimitedWidthContainer.tsx

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

+ 0 - 18
src/components/PlaceholderVideoGrid.tsx

@@ -1,18 +0,0 @@
-import React from 'react'
-
-import { Grid } from '@/shared/components'
-
-import { VideoPreview } from './VideoPreview'
-
-type PlaceholderVideoGridProps = {
-  videosCount?: number
-}
-export const PlaceholderVideoGrid: React.FC<PlaceholderVideoGridProps> = ({ videosCount = 10 }) => {
-  return (
-    <Grid>
-      {Array.from({ length: videosCount }).map((_, idx) => (
-        <VideoPreview key={idx} />
-      ))}
-    </Grid>
-  )
-}

+ 18 - 0
src/components/SkeletonLoaderVideoGrid.tsx

@@ -0,0 +1,18 @@
+import React from 'react'
+
+import { Grid } from '@/shared/components'
+
+import { VideoTile } from './VideoTile'
+
+type SkeletonLoaderVideoGridProps = {
+  videosCount?: number
+}
+export const SkeletonLoaderVideoGrid: React.FC<SkeletonLoaderVideoGridProps> = ({ videosCount = 10 }) => {
+  return (
+    <Grid>
+      {Array.from({ length: videosCount }).map((_, idx) => (
+        <VideoTile key={idx} />
+      ))}
+    </Grid>
+  )
+}

+ 2 - 2
src/components/Topbar/StudioTopbar/StudioTopbar.style.tsx

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled'
 import { Link } from 'react-router-dom'
 
-import { Avatar, Placeholder, Text } from '@/shared/components'
+import { Avatar, SkeletonLoader, Text } from '@/shared/components'
 import { colors, media, sizes, transitions, typography, zIndex } from '@/shared/theme'
 
 import { TopbarBase } from '../TopbarBase'
@@ -186,7 +186,7 @@ export const DrawerChannelsContainer = styled.div`
   overflow-x: hidden;
 `
 
-export const AvatarPlaceholder = styled(Placeholder)`
+export const AvatarSkeletonLoader = styled(SkeletonLoader)`
   border-radius: 100%;
   width: 42px;
   height: 42px;

+ 7 - 7
src/components/Topbar/StudioTopbar/StudioTopbar.tsx

@@ -6,12 +6,12 @@ import { BasicChannelFieldsFragment } from '@/api/queries'
 import { absoluteRoutes } from '@/config/routes'
 import { useDisplayDataLostWarning } from '@/hooks'
 import { AssetType, useAsset, useEditVideoSheet, useUser } from '@/providers'
-import { Button, ExpandButton, IconButton, Placeholder, Text } from '@/shared/components'
+import { Button, ExpandButton, IconButton, SkeletonLoader, Text } from '@/shared/components'
 import { SvgGlyphAddVideo, SvgGlyphCheck, SvgGlyphLogOut, SvgGlyphNewChannel } from '@/shared/icons'
 import { transitions } from '@/shared/theme'
 
 import {
-  AvatarPlaceholder,
+  AvatarSkeletonLoader,
   ChannelInfoContainer,
   DrawerChannelsContainer,
   DrawerContainer,
@@ -156,7 +156,7 @@ export const StudioTopbar: React.FC<StudioTopbarProps> = ({ hideChannelInfo, ful
               </IconButton>
             </CSSTransition>
             {activeMembershipLoading ? (
-              <ChannelInfoPlaceholder />
+              <ChannelInfoSkeletonLoader />
             ) : activeMembership?.channels.length ? (
               <ChannelInfo channel={currentChannel} memberName={activeMembership.handle} onClick={handleDrawerToggle} />
             ) : (
@@ -275,13 +275,13 @@ const NavDrawer = React.forwardRef<HTMLDivElement, NavDrawerProps>(
 )
 NavDrawer.displayName = 'NavDrawer'
 
-const ChannelInfoPlaceholder = () => {
+const ChannelInfoSkeletonLoader = () => {
   return (
     <ChannelInfoContainer>
-      <AvatarPlaceholder />
+      <AvatarSkeletonLoader />
       <TextContainer>
-        <Placeholder width="100%" height="15px" bottomSpace="6px" />
-        <Placeholder width="70%" height="10px" />
+        <SkeletonLoader width="100%" height="15px" bottomSpace="6px" />
+        <SkeletonLoader width="70%" height="10px" />
       </TextContainer>
     </ChannelInfoContainer>
   )

+ 6 - 6
src/components/VideoGallery.tsx

@@ -2,11 +2,11 @@ import styled from '@emotion/styled'
 import React, { useCallback, useMemo, useState } from 'react'
 
 import { VideoFieldsFragment } from '@/api/queries'
-import { CAROUSEL_ARROW_HEIGHT, Gallery, MIN_VIDEO_PREVIEW_WIDTH } from '@/shared/components'
+import { CAROUSEL_ARROW_HEIGHT, Gallery, MIN_VIDEO_TILE_WIDTH } from '@/shared/components'
 import { breakpointsOfGrid } from '@/shared/components/Grid'
 import { sizes } from '@/shared/theme'
 
-import { VideoPreview } from './VideoPreview'
+import { VideoTile } from './VideoTile'
 
 interface VideoFieldsWithProgress extends VideoFieldsFragment {
   progress?: number
@@ -82,11 +82,11 @@ export const VideoGallery: React.FC<VideoGalleryProps> = ({
       paddingLeft={sizes(2, true)}
       paddingTop={sizes(2, true)}
       responsive={breakpoints}
-      itemWidth={MIN_VIDEO_PREVIEW_WIDTH}
+      itemWidth={MIN_VIDEO_TILE_WIDTH}
       arrowPosition={arrowPosition}
     >
       {[...videos, ...placeholderItems]?.map((video, idx) => (
-        <StyledVideoPreview
+        <StyledVideoTile
           id={video.id}
           progress={video?.progress}
           key={idx}
@@ -101,11 +101,11 @@ export const VideoGallery: React.FC<VideoGalleryProps> = ({
   )
 }
 
-const StyledVideoPreview = styled(VideoPreview)`
+const StyledVideoTile = styled(VideoTile)`
   & + & {
     margin-left: ${sizes(6)};
   }
 
-  /* MIN_VIDEO_PREVIEW_WIDTH */
+  /* MIN_VIDEO_TILE_WIDTH */
   min-width: 300px;
 `

+ 3 - 3
src/components/VideoGrid.tsx

@@ -4,9 +4,9 @@ import React from 'react'
 import { VideoFieldsFragment } from '@/api/queries'
 import { Grid } from '@/shared/components'
 
-import { VideoPreview } from './VideoPreview'
+import { VideoTile } from './VideoTile'
 
-const StyledVideoPreview = styled(VideoPreview)`
+const StyledVideoTile = styled(VideoTile)`
   margin: 0 auto;
   width: 100%;
 `
@@ -21,7 +21,7 @@ export const VideoGrid: React.FC<VideoGridProps> = ({ videos, showChannel = true
   return (
     <Grid>
       {videos.map((v, idx) => (
-        <StyledVideoPreview
+        <StyledVideoTile
           key={idx}
           id={v.id}
           showChannel={showChannel}

+ 4 - 20
src/components/CoverVideo/CoverVideo.style.ts → src/components/VideoHero/VideoHero.style.ts

@@ -1,8 +1,8 @@
-import { css, keyframes } from '@emotion/react'
+import { css } from '@emotion/react'
 import styled from '@emotion/styled'
-import { darken, fluidRange } from 'polished'
+import { fluidRange } from 'polished'
 
-import { Button, IconButton, Placeholder, Text } from '@/shared/components'
+import { Button, IconButton, SkeletonLoader, Text } from '@/shared/components'
 import { breakpoints, colors, media, sizes } from '@/shared/theme'
 
 import { ChannelLink } from '../ChannelLink'
@@ -69,22 +69,6 @@ export const PlayerContainer = styled.div`
   ${absoluteMediaCss};
 `
 
-const pulse = keyframes`
-  0, 100% { 
-    background-color: ${colors.gray[800]}
-  }
-  50% {
-    background-color: ${darken(0.15, colors.gray[800])}
-  }
-`
-
-export const PlayerPlaceholder = styled.div`
-  ${absoluteMediaCss};
-
-  background-color: ${colors.gray[800]};
-  animation: ${pulse} 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-`
-
 export const HorizontalGradientOverlay = styled.div`
   ${absoluteMediaCss};
 
@@ -214,7 +198,7 @@ export const Title = styled(Text)`
   }
 `
 
-export const TitlePlaceholder = styled(Placeholder)`
+export const TitleSkeletonLoader = styled(SkeletonLoader)`
   margin-bottom: ${sizes(4)};
 
   ${media.medium} {

+ 10 - 13
src/components/CoverVideo/CoverVideo.tsx → src/components/VideoHero/VideoHero.tsx

@@ -4,7 +4,7 @@ import { CSSTransition } from 'react-transition-group'
 
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, useAsset } from '@/providers'
-import { Placeholder, VideoPlayer } from '@/shared/components'
+import { SkeletonLoader, VideoPlayer } from '@/shared/components'
 import { SvgPlayerPause, SvgPlayerPlay, SvgPlayerSoundOff, SvgPlayerSoundOn } from '@/shared/icons'
 import { transitions } from '@/shared/theme'
 
@@ -18,20 +18,19 @@ import {
   MediaWrapper,
   PlayButton,
   PlayerContainer,
-  PlayerPlaceholder,
   SoundButton,
   StyledChannelLink,
   Title,
   TitleContainer,
-  TitlePlaceholder,
+  TitleSkeletonLoader,
   VerticalGradientOverlay,
-} from './CoverVideo.style'
-import { useCoverVideo } from './coverVideoData'
+} from './VideoHero.style'
+import { useVideoHero } from './VideoHeroData'
 
 const VIDEO_PLAYBACK_DELAY = 1250
 
-export const CoverVideo: React.FC = () => {
-  const coverVideo = useCoverVideo()
+export const VideoHero: React.FC = () => {
+  const coverVideo = useVideoHero()
 
   const [videoPlaying, setVideoPlaying] = useState(false)
   const [displayControls, setDisplayControls] = useState(false)
@@ -61,7 +60,7 @@ export const CoverVideo: React.FC = () => {
       <MediaWrapper>
         <Media>
           <PlayerContainer>
-            {coverVideo ? (
+            {coverVideo && (
               <VideoPlayer
                 fluid
                 isInBackground
@@ -71,8 +70,6 @@ export const CoverVideo: React.FC = () => {
                 onDataLoaded={handlePlaybackDataLoaded}
                 src={coverVideo?.coverCutMediaUrl}
               />
-            ) : (
-              <PlayerPlaceholder />
             )}
           </PlayerContainer>
           {coverVideo && <HorizontalGradientOverlay />}
@@ -96,9 +93,9 @@ export const CoverVideo: React.FC = () => {
             </>
           ) : (
             <>
-              <TitlePlaceholder width={380} height={60} />
-              <Placeholder width={300} height={20} bottomSpace={4} />
-              <Placeholder width={200} height={20} />
+              <TitleSkeletonLoader width={380} height={60} />
+              <SkeletonLoader width={300} height={20} bottomSpace={4} />
+              <SkeletonLoader width={200} height={20} />
             </>
           )}
         </TitleContainer>

+ 3 - 3
src/components/CoverVideo/coverVideoData.ts → src/components/VideoHero/VideoHeroData.ts

@@ -6,7 +6,7 @@ import { VideoFieldsFragment } from '@/api/queries'
 import { COVER_VIDEO_INFO_URL } from '@/config/urls'
 import { Logger } from '@/utils/logger'
 
-import backupCoverVideoInfo from './backupCoverVideoInfo.json'
+import backupVideoHeroInfo from './backupVideoHeroInfo.json'
 
 type RawCoverInfo = {
   videoId: string
@@ -21,7 +21,7 @@ type CoverInfo =
     })
   | null
 
-export const useCoverVideo = (): CoverInfo => {
+export const useVideoHero = (): CoverInfo => {
   const [fetchedCoverInfo, setFetchedCoverInfo] = useState<RawCoverInfo | null>(null)
   const { video, error } = useVideo(fetchedCoverInfo?.videoId || '', { skip: !fetchedCoverInfo?.videoId })
 
@@ -36,7 +36,7 @@ export const useCoverVideo = (): CoverInfo => {
         setFetchedCoverInfo(response.data)
       } catch (e) {
         Logger.error(`Failed to fetch cover info from ${COVER_VIDEO_INFO_URL}. Using backup`, e)
-        setFetchedCoverInfo(backupCoverVideoInfo)
+        setFetchedCoverInfo(backupVideoHeroInfo)
       }
     }
 

+ 0 - 0
src/components/CoverVideo/backupCoverVideoInfo.json → src/components/VideoHero/backupVideoHeroInfo.json


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

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

+ 14 - 19
src/components/VideoPreview.tsx → src/components/VideoTile.tsx

@@ -5,21 +5,21 @@ import { AssetAvailability } from '@/api/queries'
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, singleDraftSelector, useAsset, useDraftStore } from '@/providers'
 import {
-  VideoPreviewBase,
-  VideoPreviewBaseMetaProps,
-  VideoPreviewBaseProps,
-  VideoPreviewPublisherProps,
-} from '@/shared/components/VideoPreviewBase/VideoPreviewBase'
+  VideoTileBase,
+  VideoTileBaseMetaProps,
+  VideoTileBaseProps,
+  VideoTilePublisherProps,
+} from '@/shared/components/VideoTileBase/VideoTileBase'
 import { copyToClipboard, openInNewTab } from '@/utils/browser'
 import { Logger } from '@/utils/logger'
 
-export type VideoPreviewProps = {
+export type VideoTileProps = {
   id?: string
   onNotFound?: () => void
-} & VideoPreviewBaseMetaProps &
-  Pick<VideoPreviewBaseProps, 'progress' | 'className'>
+} & VideoTileBaseMetaProps &
+  Pick<VideoTileBaseProps, 'progress' | 'className'>
 
-export const VideoPreview: React.FC<VideoPreviewProps> = ({ id, onNotFound, ...metaProps }) => {
+export const VideoTile: React.FC<VideoTileProps> = ({ id, onNotFound, ...metaProps }) => {
   const { video, loading, videoHref } = useVideoSharedLogic({ id, isDraft: false, onNotFound })
   const { url: thumbnailPhotoUrl } = useAsset({
     entity: video,
@@ -31,7 +31,7 @@ export const VideoPreview: React.FC<VideoPreviewProps> = ({ id, onNotFound, ...m
   })
 
   return (
-    <VideoPreviewBase
+    <VideoTileBase
       publisherMode={false}
       title={video?.title}
       channelTitle={video?.channel.title}
@@ -49,14 +49,9 @@ export const VideoPreview: React.FC<VideoPreviewProps> = ({ id, onNotFound, ...m
   )
 }
 
-export type VideoPreviewWPublisherProps = VideoPreviewProps &
-  Omit<VideoPreviewPublisherProps, 'publisherMode' | 'videoPublishState'>
-export const VideoPreviewPublisher: React.FC<VideoPreviewWPublisherProps> = ({
-  id,
-  isDraft,
-  onNotFound,
-  ...metaProps
-}) => {
+export type VideoTileWPublisherProps = VideoTileProps &
+  Omit<VideoTilePublisherProps, 'publisherMode' | 'videoPublishState'>
+export const VideoTilePublisher: React.FC<VideoTileWPublisherProps> = ({ id, isDraft, onNotFound, ...metaProps }) => {
   const { video, loading, videoHref } = useVideoSharedLogic({ id, isDraft, onNotFound })
   const draft = useDraftStore(singleDraftSelector(id ?? ''))
   const { url: thumbnailPhotoUrl } = useAsset({
@@ -71,7 +66,7 @@ export const VideoPreviewPublisher: React.FC<VideoPreviewWPublisherProps> = ({
   const hasThumbnailUploadFailed = video?.thumbnailPhotoAvailability === AssetAvailability.Pending
 
   return (
-    <VideoPreviewBase
+    <VideoTileBase
       publisherMode
       title={isDraft ? draft?.title : video?.title}
       channelTitle={video?.channel.title}

+ 5 - 5
src/components/index.ts

@@ -1,12 +1,12 @@
 export * from './VideoGallery'
-export * from './CoverVideo'
+export * from './VideoHero'
 export * from './ChannelGallery'
 export * from './Topbar/ViewerTopbar'
 export * from './Topbar/StudioTopbar'
 export * from './VideoGrid'
-export * from './PlaceholderVideoGrid'
-export * from './VideoPreview'
-export * from './ChannelPreview'
+export * from './SkeletonLoaderVideoGrid'
+export * from './VideoTile'
+export * from './ChannelCard'
 export * from './ChannelGrid'
 export { ErrorFallback as ViewErrorFallback } from './ViewErrorFallback'
 export * from './ErrorFallback'
@@ -19,7 +19,7 @@ export * from './InterruptedVideosGallery'
 export * from './ViewWrapper'
 export * from './Portal'
 export * from './Dialogs'
-export * from './StudioContainer'
+export * from './LimitedWidthContainer'
 export * from './Topbar'
 export * from './NoConnectionIndicator'
 export * from './SignInSteps'

+ 6 - 0
src/config/sorting.ts

@@ -0,0 +1,6 @@
+import { VideoOrderByInput } from '@/api/queries'
+
+export const SORT_OPTIONS = [
+  { name: 'Newest first', value: VideoOrderByInput.CreatedAtDesc },
+  { name: 'Oldest first', value: VideoOrderByInput.CreatedAtAsc },
+]

+ 11 - 7
src/hooks/useDeleteVideo.tsx

@@ -18,17 +18,21 @@ export const useDeleteVideo = () => {
       exitButton: false,
       description:
         'You will not be able to undo this. Deletion requires a blockchain transaction to complete. Currently there is no way to remove uploaded video assets.',
-      onPrimaryButtonClick: () => {
-        confirmDeleteVideo(videoId, () => onDeleteVideo?.())
-        closeDeleteVideoDialog()
+      primaryButton: {
+        text: 'Delete video',
+        onClick: () => {
+          confirmDeleteVideo(videoId, () => onDeleteVideo?.())
+          closeDeleteVideoDialog()
+        },
       },
-      onSecondaryButtonClick: () => {
-        closeDeleteVideoDialog()
+      secondaryButton: {
+        text: 'Cancel',
+        onClick: () => {
+          closeDeleteVideoDialog()
+        },
       },
       error: true,
       variant: 'warning',
-      primaryButtonText: 'Delete video',
-      secondaryButtonText: 'Cancel',
     })
   }
 

+ 12 - 8
src/hooks/useDisplayDataLostWarning.tsx

@@ -17,15 +17,19 @@ export const useDisplayDataLostWarning = () => {
       title: "Drafts' video & image data will be lost",
       description:
         "Drafts' assets aren't stored permanently. If you proceed, you will need to reselect the files again.",
-      primaryButtonText: 'Proceed',
-      secondaryButtonText: 'Cancel',
-      onPrimaryButtonClick: () => {
-        onConfirm?.()
-        closeDialog()
+      primaryButton: {
+        text: 'Proceed',
+        onClick: () => {
+          onConfirm?.()
+          closeDialog()
+        },
       },
-      onSecondaryButtonClick: () => {
-        cancelDialog(onCancel)
-        closeDialog()
+      secondaryButton: {
+        text: 'Cancel',
+        onClick: () => {
+          cancelDialog(onCancel)
+          closeDialog()
+        },
       },
       onExitClick: () => {
         cancelDialog(onCancel)

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

@@ -80,11 +80,11 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
 
 export const genericSort = <TData extends GenericData>(data: TData[], variables: SortingArgs): TData[] => {
   const { orderBy } = variables
-  if (!orderBy) {
+  if (!orderBy?.length) {
     return data
   }
 
-  const [field, direction] = orderBy.split('_')
+  const [field, direction] = orderBy[0].split('_')
   if (!field || !direction) {
     Logger.warn(`error parsing orderBy: "${orderBy}"`)
     return data

+ 15 - 0
src/mocking/accessors/orion.ts

@@ -1,4 +1,6 @@
 import {
+  GetBatchedChannelFollowsQuery,
+  GetBatchedChannelFollowsQueryVariables,
   GetBatchedVideoViewsQuery,
   GetBatchedVideoViewsQueryVariables,
   GetChannelFollowsQuery,
@@ -54,3 +56,16 @@ export const createChannelFollowsAccessor = (store: MocksStore) => (
     follows,
   }
 }
+
+export const createBatchedChannelFollowsAccessor = (store: MocksStore) => (
+  variables: GetBatchedChannelFollowsQueryVariables
+): GetBatchedChannelFollowsQuery['batchedChannelFollows'] => {
+  const { channelIdList } = variables
+
+  const batchedChannelFollows = store.batchedChannelFollows.filter((follow) => channelIdList.includes(follow.id))
+  if (!batchedChannelFollows.length) {
+    return []
+  }
+
+  return batchedChannelFollows
+}

+ 4 - 4
src/mocking/data/mockChannels.ts

@@ -2,8 +2,8 @@ import { AllChannelFieldsFragment, AssetAvailability } from '@/api/queries'
 import { languages } from '@/config/languages'
 
 import { channelAvatarSources, channelPosterSources } from './mockImages'
+import rawVideoHero from './raw/VideoHero.json'
 import rawChannels from './raw/channels.json'
-import rawCoverVideo from './raw/coverVideo.json'
 
 export type MockChannel = AllChannelFieldsFragment
 
@@ -23,10 +23,10 @@ export const regularMockChannels: MockChannel[] = rawChannels.map((rawChannel, i
 }))
 
 export const coverMockChannel: MockChannel = {
-  ...rawCoverVideo.channel,
+  ...rawVideoHero.channel,
   __typename: 'Channel',
-  createdAt: new Date(rawCoverVideo.channel.createdAt),
-  avatarPhotoUrls: [rawCoverVideo.channel.avatarPhotoUrl],
+  createdAt: new Date(rawVideoHero.channel.createdAt),
+  avatarPhotoUrls: [rawVideoHero.channel.avatarPhotoUrl],
   coverPhotoUrls: [],
   avatarPhotoAvailability: AssetAvailability.Accepted,
   coverPhotoAvailability: AssetAvailability.Invalid,

+ 8 - 8
src/mocking/data/mockVideos.ts

@@ -5,7 +5,7 @@ import mockChannels, { coverMockChannel } from './mockChannels'
 import { thumbnailSources } from './mockImages'
 import mockLicenses from './mockLicenses'
 import mockVideosMedia from './mockVideosMedia'
-import rawCoverVideo from './raw/coverVideo.json'
+import rawVideoHero from './raw/VideoHero.json'
 import rawVideos from './raw/videos.json'
 
 export type MockVideo = VideoFieldsFragment
@@ -33,16 +33,16 @@ const regularMockVideos: MockVideo[] = rawVideos.map((rawVideo, idx) => {
 })
 
 const coverMockVideo: MockVideo = {
-  ...rawCoverVideo.video,
-  createdAt: new Date(rawCoverVideo.video.createdAt),
+  ...rawVideoHero.video,
+  createdAt: new Date(rawVideoHero.video.createdAt),
   channel: coverMockChannel,
-  license: rawCoverVideo.license,
+  license: rawVideoHero.license,
   mediaAvailability: AssetAvailability.Accepted,
-  mediaUrls: [rawCoverVideo.video.mediaUrl],
-  thumbnailPhotoUrls: [rawCoverVideo.video.thumbnailPhotoUrl],
+  mediaUrls: [rawVideoHero.video.mediaUrl],
+  thumbnailPhotoUrls: [rawVideoHero.video.thumbnailPhotoUrl],
   thumbnailPhotoAvailability: AssetAvailability.Accepted,
-  mediaMetadata: rawCoverVideo.mediaMetadata,
-  duration: rawCoverVideo.mediaMetadata.duration,
+  mediaMetadata: rawVideoHero.mediaMetadata,
+  duration: rawVideoHero.mediaMetadata.duration,
   category: mockCategories[0],
   isPublic: Boolean(Math.round(Math.random())),
   isCensored: Boolean(Math.round(Math.random())),

+ 0 - 0
src/mocking/data/raw/coverVideo.json → src/mocking/data/raw/VideoHero.json


+ 9 - 0
src/mocking/handlers.ts

@@ -4,6 +4,9 @@ import {
   GetBasicChannelDocument,
   GetBasicChannelQuery,
   GetBasicChannelQueryVariables,
+  GetBatchedChannelFollowsDocument,
+  GetBatchedChannelFollowsQuery,
+  GetBatchedChannelFollowsQueryVariables,
   GetBatchedVideoViewsDocument,
   GetBatchedVideoViewsQuery,
   GetBatchedVideoViewsQueryVariables,
@@ -50,6 +53,7 @@ import { ORION_GRAPHQL_URL, QUERY_NODE_GRAPHQL_URL } from '@/config/urls'
 import { mockCategories, mockChannels, mockMemberships, mockVideos, mockWorkers } from '@/mocking/data'
 
 import {
+  createBatchedChannelFollowsAccessor,
   createBatchedVideoViewsAccessor,
   createChannelFollowsAccessor,
   createCursorPaginationAccessor,
@@ -150,6 +154,11 @@ const orionHandlers = [
     GetVideoViewsDocument,
     createVideoViewsAccessor(store)
   ),
+  createQueryHandler<GetBatchedChannelFollowsQuery, GetBatchedChannelFollowsQueryVariables>(
+    orion,
+    GetBatchedChannelFollowsDocument,
+    createBatchedChannelFollowsAccessor(store)
+  ),
   createQueryHandler<GetChannelFollowsQuery, GetChannelFollowsQueryVariables>(
     orion,
     GetChannelFollowsDocument,

+ 5 - 0
src/mocking/store.ts

@@ -5,6 +5,7 @@ export const createStore = (data: VideosChannelsData): MocksStore => {
     videoViews: {},
     channelFollows: {},
     batchedVideoViews: [],
+    batchedChannelFollows: [],
   }
 
   const { videos, channels } = data
@@ -21,6 +22,10 @@ export const createStore = (data: VideosChannelsData): MocksStore => {
     if (c.follows) {
       store.channelFollows[c.id] = c.follows
     }
+    store.batchedChannelFollows.push({
+      id: c.id,
+      follows: c.follows || 0,
+    })
   })
 
   return store

+ 5 - 1
src/mocking/types.ts

@@ -12,7 +12,7 @@ export type FilteringArgs<TWhere = VideoWhereInput | VideoWhereUniqueInput | Mem
   where?: TWhere | null
 }
 export type SortingArgs = {
-  orderBy?: string
+  orderBy?: [string]
 }
 export type GenericData = Record<string, unknown>
 export type CountData = {
@@ -36,6 +36,10 @@ export type MocksStore = {
     id: string
   }[]
   channelFollows: Record<string, number>
+  batchedChannelFollows: {
+    follows: number
+    id: string
+  }[]
 }
 export type Link = ReturnType<typeof graphql.link>
 export type BaseDataQuery<TQueryData = unknown> = {

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

@@ -30,9 +30,11 @@ export const useTransaction = (): HandleTransactionFn => {
     title: 'Something went wrong...',
     description:
       'Some unexpected error was encountered. If this persists, our Discord community may be a good place to find some help.',
-    secondaryButtonText: 'Close',
-    onSecondaryButtonClick: () => {
-      closeErrorDialog()
+    secondaryButton: {
+      text: 'Close',
+      onClick: () => {
+        closeErrorDialog()
+      },
     },
     onExitClick: () => {
       closeErrorDialog()
@@ -93,8 +95,10 @@ export const useTransaction = (): HandleTransactionFn => {
             variant: 'success',
             title: successMessage.title,
             description: successMessage.description,
-            secondaryButtonText: 'Close',
-            onSecondaryButtonClick: handleDialogClose,
+            secondaryButton: {
+              text: 'Close',
+              onClick: handleDialogClose,
+            },
             onExitClick: handleDialogClose,
           })
         })

+ 2 - 2
src/shared/components/ActionBar/ActionBarTransaction.style.ts

@@ -2,7 +2,7 @@ import styled from '@emotion/styled'
 
 import { media, transitions, zIndex } from '@/shared/theme'
 
-import { Checkout } from '../Checkout'
+import { ProgressDrawer } from '../ProgressDrawer'
 
 type ActionBarTransactionWrapperProps = {
   fullWidth?: boolean
@@ -23,7 +23,7 @@ export const ActionBarTransactionWrapper = styled.div<ActionBarTransactionWrappe
   }
 `
 
-export const StyledCheckout = styled(Checkout)`
+export const StyledProgressDrawer = styled(ProgressDrawer)`
   display: none;
   ${media.medium} {
     position: absolute;

+ 5 - 5
src/shared/components/ActionBar/ActionBarTransaction.tsx

@@ -1,25 +1,25 @@
 import React from 'react'
 
 import { ActionBar, ActionBarProps } from './ActionBar'
-import { ActionBarTransactionWrapper, StyledCheckout } from './ActionBarTransaction.style'
+import { ActionBarTransactionWrapper, StyledProgressDrawer } from './ActionBarTransaction.style'
 
-import { Step } from '../Checkout/Checkout'
+import { Step } from '../ProgressDrawer/ProgressDrawer'
 
 export type ActionBarTransactionProps = {
   fee: number
-  checkoutSteps?: Step[]
+  progressDrawerSteps?: Step[]
 } & Omit<ActionBarProps, 'primaryText' | 'secondaryText'>
 
 export const ActionBarTransaction: React.FC<ActionBarTransactionProps> = ({
   fee,
   fullWidth,
   isActive,
-  checkoutSteps,
+  progressDrawerSteps,
   ...actionBarArgs
 }) => {
   return (
     <ActionBarTransactionWrapper fullWidth={fullWidth} isActive={isActive}>
-      {checkoutSteps?.length ? <StyledCheckout steps={checkoutSteps} /> : null}
+      {progressDrawerSteps?.length ? <StyledProgressDrawer steps={progressDrawerSteps} /> : null}
       <ActionBar
         {...actionBarArgs}
         fullWidth={fullWidth}

+ 16 - 3
src/shared/components/Avatar/Avatar.style.tsx

@@ -5,9 +5,9 @@ import { TransitionGroup } from 'react-transition-group'
 import { SvgAvatarSilhouette } from '@/shared/illustrations'
 import { colors, media, transitions, typography } from '@/shared/theme'
 
-import { Placeholder } from '../Placeholder'
+import { SkeletonLoader } from '../SkeletonLoader'
 
-export type AvatarSize = 'preview' | 'cover' | 'view' | 'default' | 'fill' | 'small'
+export type AvatarSize = 'preview' | 'cover' | 'view' | 'default' | 'fill' | 'small' | 'channel'
 
 type ContainerProps = {
   size: AvatarSize
@@ -35,6 +35,17 @@ const coverAvatarCss = css`
   }
 `
 
+const channelAvatarCss = css`
+  width: 88px;
+  min-width: 88px;
+  height: 88px;
+  ${media.medium} {
+    width: 136px;
+    min-width: 136px;
+    height: 136px;
+  }
+`
+
 const viewAvatarCss = css`
   width: 128px;
   min-width: 128px;
@@ -71,6 +82,8 @@ const getAvatarSizeCss = (size: AvatarSize): SerializedStyles => {
       return coverAvatarCss
     case 'view':
       return viewAvatarCss
+    case 'channel':
+      return channelAvatarCss
     case 'fill':
       return fillAvatarCss
     case 'small':
@@ -126,7 +139,7 @@ export const EditButton = styled.button<EditButtonProps>`
   }
 `
 
-export const StyledPlaceholder = styled(Placeholder)`
+export const StyledSkeletonLoader = styled(SkeletonLoader)`
   position: absolute;
   left: 0;
 `

+ 2 - 2
src/shared/components/Avatar/Avatar.tsx

@@ -11,7 +11,7 @@ import {
   NewChannelAvatar,
   SilhouetteAvatar,
   StyledImage,
-  StyledPlaceholder,
+  StyledSkeletonLoader,
   StyledTransitionGroup,
 } from './Avatar.style'
 
@@ -67,7 +67,7 @@ export const Avatar: React.FC<AvatarProps> = ({
             classNames={transitions.names.fade}
           >
             {loading ? (
-              <StyledPlaceholder rounded />
+              <StyledSkeletonLoader rounded />
             ) : assetUrl ? (
               <StyledImage src={assetUrl} onError={onError} />
             ) : hasAvatarUploadFailed ? (

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

@@ -3,7 +3,7 @@ import styled from '@emotion/styled'
 
 import { sizes, transitions } from '@/shared/theme'
 
-import { Placeholder } from '../Placeholder'
+import { SkeletonLoader } from '../SkeletonLoader'
 import { ToggleButton } from '../ToggleButton'
 
 const fadeIn = keyframes`
@@ -24,7 +24,7 @@ export const Container = styled.div`
   flex-wrap: wrap;
 `
 
-export const StyledPlaceholder = styled(Placeholder)`
+export const StyledSkeletonLoader = styled(SkeletonLoader)`
   margin: 0 ${sizes(3)} ${sizes(3)} 0;
 `
 

+ 2 - 2
src/shared/components/CategoryPicker/CategoryPicker.tsx

@@ -2,7 +2,7 @@ import React from 'react'
 
 import { VideoCategoryFieldsFragment } from '@/api/queries'
 
-import { Container, StyledPlaceholder, StyledToggleButton } from './CategoryPicker.style'
+import { Container, StyledSkeletonLoader, StyledToggleButton } from './CategoryPicker.style'
 
 type CategoryPickerProps = {
   categories?: VideoCategoryFieldsFragment[]
@@ -37,7 +37,7 @@ export const CategoryPicker: React.FC<CategoryPickerProps> = ({
     <Container className={className}>
       {isLoading
         ? CATEGORY_PLACEHOLDER_WIDTHS.map((width, idx) => (
-            <StyledPlaceholder key={`placeholder-${idx}`} width={width} height="48px" />
+            <StyledSkeletonLoader key={`placeholder-${idx}`} width={width} height="48px" />
           ))
         : displayedCategories.map((category) => (
             <StyledToggleButton

+ 6 - 6
src/shared/components/ChannelPreviewBase/ChannelPreview.stories.tsx → src/shared/components/ChannelCardBase/ChannelCard.stories.tsx

@@ -2,11 +2,11 @@ import { Meta, Story } from '@storybook/react'
 import React from 'react'
 import { BrowserRouter } from 'react-router-dom'
 
-import { ChannelPreviewBase, ChannelPreviewBaseProps } from './ChannelPreviewBase'
+import { ChannelCardBase, ChannelCardBaseProps } from './ChannelCardBase'
 
 export default {
-  title: 'Shared/C/ChannelPreview',
-  component: ChannelPreviewBase,
+  title: 'Shared/C/ChannelCard',
+  component: ChannelCardBase,
   argTypes: {
     className: { table: { disable: true } },
     onClick: { table: { disable: true } },
@@ -14,8 +14,8 @@ export default {
   decorators: [(story) => <BrowserRouter>{story()}</BrowserRouter>],
 } as Meta
 
-const Template: Story<ChannelPreviewBaseProps> = (args) => <ChannelPreviewBase {...args} />
-const PlaceholderTemplate: Story<ChannelPreviewBaseProps> = (args) => <ChannelPreviewBase {...args} />
+const Template: Story<ChannelCardBaseProps> = (args) => <ChannelCardBase {...args} />
+const SkeletonLoaderTemplate: Story<ChannelCardBaseProps> = (args) => <ChannelCardBase {...args} />
 
 export const Regular = Template.bind({})
 Regular.args = {
@@ -24,4 +24,4 @@ Regular.args = {
   videoCount: 0,
   loading: false,
 }
-export const Placeholder = PlaceholderTemplate.bind({})
+export const SkeletonLoader = SkeletonLoaderTemplate.bind({})

+ 0 - 0
src/shared/components/ChannelPreviewBase/ChannelPreviewBase.style.tsx → src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx


+ 8 - 8
src/shared/components/ChannelPreviewBase/ChannelPreviewBase.tsx → src/shared/components/ChannelCardBase/ChannelCardBase.tsx

@@ -13,11 +13,11 @@ import {
   TextBase,
   VideoCount,
   VideoCountContainer,
-} from './ChannelPreviewBase.style'
+} from './ChannelCardBase.style'
 
-import { Placeholder } from '../Placeholder'
+import { SkeletonLoader } from '../SkeletonLoader'
 
-export type ChannelPreviewBaseProps = {
+export type ChannelCardBaseProps = {
   assetUrl?: string | null
   title?: string | null
   videoCount?: number
@@ -27,7 +27,7 @@ export type ChannelPreviewBaseProps = {
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
 }
 
-export const ChannelPreviewBase: React.FC<ChannelPreviewBaseProps> = ({
+export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
   assetUrl,
   title,
   videoCount,
@@ -57,24 +57,24 @@ export const ChannelPreviewBase: React.FC<ChannelPreviewBaseProps> = ({
           >
             <InnerContainer animated={isAnimated}>
               <AvatarContainer>
-                {loading ? <Placeholder rounded /> : <StyledAvatar assetUrl={assetUrl} />}
+                {loading ? <SkeletonLoader rounded /> : <StyledAvatar assetUrl={assetUrl} />}
               </AvatarContainer>
               <Info>
                 {loading ? (
-                  <Placeholder width="140px" height="16px" />
+                  <SkeletonLoader width="140px" height="16px" />
                 ) : (
                   <TextBase variant="h6">{title || '\u00A0'}</TextBase>
                 )}
                 <VideoCountContainer>
                   {loading ? (
-                    <Placeholder width="140px" height="16px" />
+                    <SkeletonLoader width="140px" height="16px" />
                   ) : (
                     <CSSTransition
                       in={!!videoCount}
                       timeout={parseInt(transitions.timings.loading) * 0.5}
                       classNames={transitions.names.fade}
                     >
-                      <VideoCount variant="subtitle2">{videoCount ? `${videoCount} Uploads` : '⠀'}</VideoCount>
+                      <VideoCount variant="subtitle2">{`${videoCount ?? ''} Uploads`}</VideoCount>
                     </CSSTransition>
                   )}
                 </VideoCountContainer>

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

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

+ 3 - 93
src/shared/components/ChannelCover/ChannelCover.style.ts

@@ -13,12 +13,6 @@ export const CONTENT_OVERLAP_MAP = {
   XLARGE: 200,
   XXLARGE: 300,
 }
-const GRADIENT_OVERLAP = 50
-const GRADIENT_HEIGHT = 100
-
-type CoverImageProps = {
-  $src: string
-}
 
 export const MediaWrapper = styled.div`
   margin: 0 calc(-1 * var(--global-horizontal-padding));
@@ -32,100 +26,20 @@ export const Media = styled.div`
   padding-top: 25%;
   position: relative;
   z-index: ${zIndex.background};
+  overflow: hidden;
 `
 
-export const CoverImage = styled.div<CoverImageProps>`
+export const CoverImage = styled.img`
+  width: 100%;
   position: absolute;
   top: 0;
   right: 0;
   bottom: 0;
   left: 0;
-  background-repeat: no-repeat;
-  background-position: center;
-  background-attachment: local;
-  background-size: cover;
-
-  /* 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-image: linear-gradient(0deg, black 0%, rgba(0, 0, 0, 0) ${GRADIENT_HEIGHT / 4}px),
-    url(${({ $src }) => $src});
-
-  ${media.small} {
-    background-image: linear-gradient(
-        0deg,
-        black 0%,
-        black ${Math.min(CONTENT_OVERLAP_MAP.SMALL - GRADIENT_OVERLAP, 0)}px,
-        rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.SMALL - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-      ),
-      url(${({ $src }) => $src});
-  }
-
-  ${media.medium} {
-    background-image: linear-gradient(
-        0deg,
-        black 0%,
-        black ${Math.min(CONTENT_OVERLAP_MAP.MEDIUM - GRADIENT_OVERLAP, 0)}px,
-        rgba(0, 0, 0, 0) ${CONTENT_OVERLAP_MAP.MEDIUM - GRADIENT_OVERLAP + GRADIENT_HEIGHT}px
-      ),
-      url(${({ $src }) => $src});
-  }
-
-  ${media.large} {
-    background-image: 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
-      ),
-      url(${({ $src }) => $src});
-  }
-
-  ${media.xlarge} {
-    background-image: 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
-      ),
-      url(${({ $src }) => $src});
-  }
-
-  ${media.xxlarge} {
-    background-image: 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
-      ),
-      url(${({ $src }) => $src});
-  }
 `
 
 export const CoverWrapper = styled.div`
   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 */
-  margin-bottom: -${CONTENT_OVERLAP_MAP.BASE}px;
-  ${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;
-  }
 `
 
 export const EditableControls = styled.div`
@@ -144,10 +58,6 @@ export const EditableControls = styled.div`
       opacity: 1;
     }
   }
-
-  ${media.xlarge} {
-    height: 80%;
-  }
 `
 
 export const EditCoverDesktopOverlay = styled.div`

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

@@ -56,7 +56,7 @@ export const ChannelCover: React.FC<ChannelCoverProps> = ({
               classNames={transitions.names.fade}
             >
               {assetUrl ? (
-                <CoverImage $src={assetUrl} />
+                <CoverImage src={assetUrl} />
               ) : hasCoverUploadFailed ? (
                 <FailedUploadContainer>
                   <SvgLargeUploadFailed />

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

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

+ 0 - 1
src/shared/components/Checkout/index.tsx

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

+ 7 - 7
src/shared/components/CircularProgressbar/CircularProgressbar.stories.tsx → src/shared/components/CircularProgress/CircularProgress.stories.tsx

@@ -1,24 +1,24 @@
 import { Meta, Story } from '@storybook/react'
 import React from 'react'
 
-import { CircularProgressbar, CircularProgressbarProps } from './CircularProgressbar'
+import { CircularProgress, CircularProgressProps } from './CircularProgress'
 
 export default {
-  title: 'Shared/C/CircularProgressbar',
-  component: CircularProgressbar,
+  title: 'Shared/C/CircularProgress',
+  component: CircularProgress,
   argTypes: {},
 } as Meta
 
-const SingleTemplate: Story<CircularProgressbarProps> = (args) => (
+const SingleTemplate: Story<CircularProgressProps> = (args) => (
   <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
     <div style={{ width: '24px', height: '24px' }}>
-      <CircularProgressbar {...args}></CircularProgressbar>
+      <CircularProgress {...args}></CircularProgress>
     </div>
     <div style={{ width: '48px', height: '48px' }}>
-      <CircularProgressbar {...args}></CircularProgressbar>
+      <CircularProgress {...args}></CircularProgress>
     </div>
     <div style={{ width: '96px', height: '96px' }}>
-      <CircularProgressbar {...args}></CircularProgressbar>
+      <CircularProgress {...args}></CircularProgress>
     </div>
   </div>
 )

+ 0 - 0
src/shared/components/CircularProgressbar/CircularProgressbar.style.tsx → src/shared/components/CircularProgress/CircularProgress.style.tsx


+ 4 - 4
src/shared/components/CircularProgressbar/CircularProgressbar.tsx → src/shared/components/CircularProgress/CircularProgress.tsx

@@ -1,6 +1,6 @@
 import * as React from 'react'
 
-import { Background, SVG, StyledPath, Trail, TrailVariant } from './CircularProgressbar.style'
+import { Background, SVG, StyledPath, Trail, TrailVariant } from './CircularProgress.style'
 
 export const VIEWBOX_WIDTH = 100
 export const VIEWBOX_HEIGHT = 100
@@ -8,7 +8,7 @@ export const VIEWBOX_HEIGHT_HALF = VIEWBOX_HEIGHT / 2
 export const VIEWBOX_CENTER_X = VIEWBOX_WIDTH / 2
 export const VIEWBOX_CENTER_Y = VIEWBOX_HEIGHT / 2
 
-export type CircularProgressbarProps = {
+export type CircularProgressProps = {
   value: number
   circleRatio?: number
   counterClockwise?: boolean
@@ -22,7 +22,7 @@ export type CircularProgressbarProps = {
   noTrail?: boolean
 }
 
-export const CircularProgressbar: React.FC<CircularProgressbarProps> = ({
+export const CircularProgress: React.FC<CircularProgressProps> = ({
   value,
   background = false,
   backgroundPadding = 0,
@@ -50,7 +50,7 @@ export const CircularProgressbar: React.FC<CircularProgressbarProps> = ({
     <>
       <SVG viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`} className={className}>
         {background ? <Background cx={VIEWBOX_CENTER_X} cy={VIEWBOX_CENTER_Y} r={VIEWBOX_HEIGHT_HALF} /> : null}
-        {noTrail && (
+        {!noTrail && (
           <Trail
             counterClockwise={counterClockwise}
             dashRatio={circleRatio}

+ 1 - 1
src/shared/components/CircularProgressbar/Path.tsx → src/shared/components/CircularProgress/Path.tsx

@@ -1,6 +1,6 @@
 import React from 'react'
 
-import { VIEWBOX_CENTER_X, VIEWBOX_CENTER_Y } from './CircularProgressbar'
+import { VIEWBOX_CENTER_X, VIEWBOX_CENTER_Y } from './CircularProgress'
 
 type PathProps = {
   className?: string

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

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

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

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

+ 5 - 5
src/shared/components/DismissibleMessage/DismissibleMessage.stories.tsx → src/shared/components/DismissibleBanner/DismissibleBanner.stories.tsx

@@ -3,13 +3,13 @@ import React from 'react'
 
 import { usePersonalDataStore } from '@/providers'
 
-import { DismissibleMessage, DismissibleMessageProps } from './DismissibleMessage'
+import { DismissibleBanner, DismissibleBannerProps } from './DismissibleBanner'
 
 import { Button } from '../Button'
 
 export default {
-  title: 'Shared/D/DismissibleMessage',
-  component: DismissibleMessage,
+  title: 'Shared/D/DismissibleBanner',
+  component: DismissibleBanner,
   argTypes: {
     id: {
       defaultValue: 'video-drafts',
@@ -34,11 +34,11 @@ export default {
   decorators: [(Story) => <Story />],
 } as Meta
 
-const Template: Story<DismissibleMessageProps> = (args) => {
+const Template: Story<DismissibleBannerProps> = (args) => {
   const updateDismissedMessages = usePersonalDataStore((state) => state.actions.updateDismissedMessages)
   return (
     <>
-      <DismissibleMessage {...args} />
+      <DismissibleBanner {...args} />
       <Button
         onClick={() => {
           updateDismissedMessages(args.id, false)

+ 2 - 2
src/shared/components/DismissibleMessage/DismissibleMessage.tsx → src/shared/components/DismissibleBanner/DismissibleBanner.tsx

@@ -4,11 +4,11 @@ import { usePersonalDataStore } from '@/providers'
 
 import { Banner, BannerProps } from '../Banner'
 
-export type DismissibleMessageProps = {
+export type DismissibleBannerProps = {
   id: string
 } & Omit<BannerProps, 'onExitClick'>
 
-export const DismissibleMessage: React.FC<DismissibleMessageProps> = ({ id, ...dismissedMessageProps }) => {
+export const DismissibleBanner: React.FC<DismissibleBannerProps> = ({ id, ...dismissedMessageProps }) => {
   const isDismissedMessage = usePersonalDataStore((state) =>
     state.dismissedMessages.some((message) => message.id === id)
   )

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

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

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

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

+ 38 - 0
src/shared/components/EmptyFallback/EmptyFallback.stories.tsx

@@ -0,0 +1,38 @@
+import { Story } from '@storybook/react'
+import React from 'react'
+
+import { Button } from '@/shared/components'
+import { SvgGlyphUpload } from '@/shared/icons'
+
+import { EmptyFallback, EmptyFallbackProps } from './EmptyFallback'
+
+export default {
+  title: 'Shared/E/EmptyFallback',
+  argTypes: {
+    title: {
+      control: { type: 'text' },
+      defaultValue: 'No draft here yet',
+    },
+    subtitle: {
+      control: { type: 'text' },
+      defaultValue: 'Each unfinished project will be saved here as a draft. Start publishing to see something here.',
+    },
+    variant: {
+      control: { type: 'select', options: ['small', 'large'] },
+      defaultValue: 'large',
+    },
+  },
+}
+
+const Template: Story<EmptyFallbackProps> = (args) => (
+  <EmptyFallback
+    {...args}
+    button={
+      <Button icon={<SvgGlyphUpload />} variant="secondary" size="large">
+        Upload video
+      </Button>
+    }
+  />
+)
+
+export const Default = Template.bind({})

+ 40 - 0
src/shared/components/EmptyFallback/EmptyFallback.styles.ts

@@ -0,0 +1,40 @@
+import styled from '@emotion/styled'
+
+import { media, sizes } from '@/shared/theme'
+
+import { EmptyFallbackSizes } from './EmptyFallback'
+
+import { Text } from '../Text'
+
+export const Container = styled.div<{ variant?: EmptyFallbackSizes }>`
+  margin: ${sizes(10)} auto;
+  display: grid;
+  place-items: center;
+
+  ${media.compact} {
+    width: ${({ variant }) => (variant === 'large' ? sizes(90) : 'auto')};
+  }
+
+  ${({ variant }) => `
+    ${Title} {
+      margin-top: ${sizes(variant === 'large' ? 10 : 6)};
+  `}
+`
+
+export const Message = styled.div`
+  display: flex;
+  flex-direction: column;
+  text-align: center;
+`
+
+export const Title = styled(Text)`
+  line-height: 1.25;
+`
+export const Subtitle = styled(Text)`
+  margin-top: ${sizes(2)};
+  line-height: ${sizes(5)};
+`
+
+export const ButtonWrapper = styled.div`
+  margin-top: ${sizes(6)};
+`

+ 40 - 0
src/shared/components/EmptyFallback/EmptyFallback.tsx

@@ -0,0 +1,40 @@
+import React, { FC, ReactNode } from 'react'
+
+import { SvgEmptyStateIllustration } from '@/shared/illustrations'
+
+import { ButtonWrapper, Container, Message, Subtitle, Title } from './EmptyFallback.styles'
+
+export type EmptyFallbackSizes = 'small' | 'large'
+
+export type EmptyFallbackProps = {
+  title: string
+  subtitle?: string | null
+  variant?: EmptyFallbackSizes
+  button?: ReactNode
+}
+
+const ILLUSTRATION_SIZES = {
+  small: {
+    width: 180,
+    height: 114,
+  },
+  large: {
+    width: 240,
+    height: 152,
+  },
+}
+
+export const EmptyFallback: FC<EmptyFallbackProps> = ({ title, subtitle, variant = 'large', button }) => (
+  <Container variant={variant}>
+    <SvgEmptyStateIllustration width={ILLUSTRATION_SIZES[variant].width} height={ILLUSTRATION_SIZES[variant].height} />
+    <Message>
+      {title && <Title variant={variant === 'large' ? 'h4' : 'body1'}>{title}</Title>}
+      {subtitle && (
+        <Subtitle variant="body2" secondary>
+          {subtitle}
+        </Subtitle>
+      )}
+    </Message>
+    <ButtonWrapper>{button}</ButtonWrapper>
+  </Container>
+)

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

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

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

@@ -2,7 +2,7 @@ import styled from '@emotion/styled'
 
 import { colors, sizes, transitions, typography } from '@/shared/theme'
 
-import { CircularProgressbar } from '../CircularProgressbar'
+import { CircularProgress } from '../CircularProgress'
 import { Text } from '../Text'
 
 type StepProps = {
@@ -77,7 +77,7 @@ export const FileName = styled(Text)`
   text-overflow: ellipsis;
 `
 
-export const StyledProgress = styled(CircularProgressbar)`
+export const StyledProgress = styled(CircularProgress)`
   width: ${sizes(7)};
   height: ${sizes(7)};
 `

+ 2 - 2
src/shared/components/Grid/Grid.tsx

@@ -4,7 +4,7 @@ import React, { useRef } from 'react'
 import useResizeObserver from 'use-resize-observer'
 
 import { media, sizes } from '../../theme'
-import { MIN_VIDEO_PREVIEW_WIDTH } from '../VideoPreviewBase'
+import { MIN_VIDEO_TILE_WIDTH } from '../VideoTileBase'
 
 const toPx = (n: number | string) => (typeof n === 'number' ? `${n}px` : n)
 
@@ -23,7 +23,7 @@ export const Grid: React.FC<GridProps> = ({
   onResize,
   repeat = 'fill',
   maxColumns = 6,
-  minWidth = MIN_VIDEO_PREVIEW_WIDTH,
+  minWidth = MIN_VIDEO_TILE_WIDTH,
   ...props
 }) => {
   const gridRef = useRef<HTMLImageElement>(null)

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

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

+ 0 - 32
src/shared/components/Placeholder/Placeholder.tsx

@@ -1,32 +0,0 @@
-import { keyframes } from '@emotion/react'
-import styled from '@emotion/styled'
-import { darken } from 'polished'
-
-import { colors } from '@/shared/theme'
-
-type PlaceholderProps = {
-  width?: string | number
-  height?: string | number
-  bottomSpace?: string | number
-  rounded?: boolean
-}
-
-const getPropValue = (v: string | number) => (typeof v === 'string' ? v : `${v}px`)
-
-const pulse = keyframes`
-  0, 100% { 
-    background-color: ${colors.gray[400]}
-  }
-  50% {
-    background-color: ${darken(0.15, colors.gray[400])}
-  }
-`
-
-export const Placeholder = styled.div<PlaceholderProps>`
-  width: ${({ width = '100%' }) => getPropValue(width)};
-  height: ${({ height = '100%' }) => getPropValue(height)};
-  margin-bottom: ${({ bottomSpace = 0 }) => getPropValue(bottomSpace)};
-  border-radius: ${({ rounded = false }) => (rounded ? '100%' : '0')};
-  background-color: ${colors.gray['400']};
-  animation: ${pulse} 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-`

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

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

+ 5 - 5
src/shared/components/Checkout/Checkout.stories.tsx → src/shared/components/ProgressDrawer/ProgressDrawer.stories.tsx

@@ -1,15 +1,15 @@
 import { Meta, Story } from '@storybook/react'
 import React, { useState } from 'react'
 
-import { Checkout, CheckoutProps } from './Checkout'
+import { ProgressDrawer, ProgressDrawerProps } from './ProgressDrawer'
 
 export default {
-  title: 'Shared/C/Checkout',
-  component: Checkout,
+  title: 'Shared/P/ProgressDrawer',
+  component: ProgressDrawer,
   argTypes: {},
 } as Meta
 
-const SingleTemplate: Story<CheckoutProps> = (args) => {
+const SingleTemplate: Story<ProgressDrawerProps> = (args) => {
   const [addChannel, setaddChannel] = useState(true)
   const [addDescription, setaddDescription] = useState(false)
   const [addAvatar, setaddAvatar] = useState(false)
@@ -21,7 +21,7 @@ const SingleTemplate: Story<CheckoutProps> = (args) => {
     { title: 'Add Avatar', onClick: () => setaddAvatar((value) => !value), completed: addAvatar },
     { title: 'Add Cover Image', onClick: () => setaddCoverImage((value) => !value), completed: addCoverImage },
   ]
-  return <Checkout {...args} steps={steps}></Checkout>
+  return <ProgressDrawer {...args} steps={steps}></ProgressDrawer>
 }
 
 export const Single = SingleTemplate.bind({})

+ 2 - 2
src/shared/components/Checkout/Checkout.styles.tsx → src/shared/components/ProgressDrawer/ProgressDrawer.styles.tsx

@@ -4,7 +4,7 @@ import styled from '@emotion/styled'
 import { SvgGlyphChevronDown } from '@/shared/icons'
 import { colors, sizes, transitions } from '@/shared/theme'
 
-import { CircularProgressbar } from '../CircularProgressbar'
+import { CircularProgress } from '../CircularProgress'
 import { Text } from '../Text'
 
 export const Container = styled.div`
@@ -16,7 +16,7 @@ export const CircularProgresaBarContainer = styled.div`
   align-items: center;
 `
 
-export const StyledCircularProgressbar = styled(CircularProgressbar)`
+export const StyledCircularProgress = styled(CircularProgress)`
   margin-left: ${sizes(2)};
   margin-right: ${sizes(2)};
   width: ${sizes(6)};

+ 5 - 5
src/shared/components/Checkout/Checkout.tsx → src/shared/components/ProgressDrawer/ProgressDrawer.tsx

@@ -14,19 +14,19 @@ import {
   StepsCompletedText,
   StepsContainer,
   StepsProgressContainer,
-  StyledCircularProgressbar,
+  StyledCircularProgress,
   StyledSvgGlyphChevronDown,
-} from './Checkout.styles'
+} from './ProgressDrawer.styles'
 
 import { IconButton } from '../IconButton'
 import { Text } from '../Text'
 
 export type Step = { title: string; onClick: () => void; completed: boolean }
-export type CheckoutProps = {
+export type ProgressDrawerProps = {
   steps: Step[]
   className?: string
 }
-export const Checkout: React.FC<CheckoutProps> = ({ steps, className }) => {
+export const ProgressDrawer: React.FC<ProgressDrawerProps> = ({ steps, className }) => {
   const stepsCompletedNumber = steps.filter(({ completed }) => completed).length
   const [isHidden, setIsHidden] = useState(false)
   return (
@@ -51,7 +51,7 @@ export const Checkout: React.FC<CheckoutProps> = ({ steps, className }) => {
       <BottomRowContainer>
         <StepsProgressContainer>
           <CircularProgresaBarContainer>
-            <StyledCircularProgressbar value={stepsCompletedNumber} maxValue={steps.length} />
+            <StyledCircularProgress value={stepsCompletedNumber} maxValue={steps.length} />
             <StepsCompletedText variant="body2">
               {stepsCompletedNumber}/{steps.length}
             </StepsCompletedText>

+ 1 - 0
src/shared/components/ProgressDrawer/index.tsx

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

+ 1 - 1
src/shared/components/Select/Select.style.ts

@@ -44,7 +44,7 @@ export const SelectMenu = styled.ul<SelectMenuProps>`
   width: 100%;
   max-height: 300px;
   position: absolute;
-  overflow-y: scroll;
+  overflow-y: auto;
   z-index: 1;
   padding: 0;
   margin-top: ${({ isOpen }) => (isOpen ? sizes(1) : 0)};

+ 31 - 0
src/shared/components/SkeletonLoader/SkeletonLoader.stories.tsx

@@ -0,0 +1,31 @@
+import { Meta, Story } from '@storybook/react'
+import React from 'react'
+
+import { SkeletonLoader, SkeletonLoaderProps } from './SkeletonLoader'
+
+export default {
+  title: 'Shared/S/SkeletonLoader',
+  component: SkeletonLoader,
+  argTypes: {
+    width: {
+      defaultValue: 500,
+      control: {
+        type: 'range',
+        min: 200,
+        max: 500,
+      },
+    },
+    height: {
+      defaultValue: 200,
+      control: {
+        type: 'range',
+        min: 200,
+        max: 500,
+      },
+    },
+  },
+} as Meta
+
+const Template: Story<SkeletonLoaderProps> = (args) => <SkeletonLoader {...args} />
+
+export const Default = Template.bind({})

+ 62 - 0
src/shared/components/SkeletonLoader/SkeletonLoader.tsx

@@ -0,0 +1,62 @@
+import { keyframes } from '@emotion/react'
+import styled from '@emotion/styled'
+import React from 'react'
+
+import { colors } from '@/shared/theme'
+
+export type SkeletonLoaderProps = {
+  width?: string | number
+  height?: string | number
+  bottomSpace?: string | number
+  rounded?: boolean
+} & React.HTMLAttributes<HTMLDivElement>
+
+const getPropValue = (v: string | number) => (typeof v === 'string' ? v : `${v}px`)
+
+const pulse = keyframes`
+  0% {
+    transform: translateX(0%);
+  }
+  49.999% {
+    transform: translateX(100%);
+  }
+  50% {
+    transform: translateX(-100%);
+  }
+`
+
+export const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ className, ...props }) => (
+  <SkeletonLoaderContainer {...props} className={className}>
+    <SkeletonLoaderAnimated {...props} />
+  </SkeletonLoaderContainer>
+)
+
+const SkeletonLoaderContainer = styled.div<SkeletonLoaderProps>`
+  width: ${({ width = '100%' }) => getPropValue(width)};
+  height: ${({ height = '100%' }) => getPropValue(height)};
+  margin-bottom: ${({ bottomSpace = 0 }) => getPropValue(bottomSpace)};
+  border-radius: ${({ rounded = false }) => (rounded ? '100%' : '0')};
+  background-color: ${colors.gray['800']};
+  overflow: hidden;
+
+  /* Safari fix
+     https://stackoverflow.com/questions/49066011/overflow-hidden-with-border-radius-not-working-on-safari */
+  transform: translateZ(0);
+`
+
+const SkeletonLoaderAnimated = styled.div<SkeletonLoaderProps>`
+  height: ${({ height = '100%' }) => getPropValue(height)};
+  transform: translateX(0%);
+  background: linear-gradient(
+    104deg,
+    ${colors.gray['700']}00 15%,
+    ${colors.gray['700']}3F 25%,
+    ${colors.gray['700']}7F 30%,
+    ${colors.gray['700']}FF 48%,
+    ${colors.gray['700']}FF 52%,
+    ${colors.gray['700']}7F 70%,
+    ${colors.gray['700']}3F 75%,
+    ${colors.gray['700']}00 85%
+  );
+  animation: ${pulse} 1.5s linear infinite;
+`

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

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

+ 5 - 2
src/shared/components/Tabs/Tabs.styles.tsx

@@ -33,6 +33,7 @@ export const TabsGroup = styled.div`
 `
 
 export const Tab = styled.div<TabProps>`
+  transition: border-bottom 0.125s ease, color 0.125s ease;
   width: ${TAB_WIDTH}px;
   min-width: ${TAB_WIDTH}px;
   padding: 22px 0;
@@ -40,9 +41,11 @@ export const Tab = styled.div<TabProps>`
   color: ${(props) => (props.selected ? colors.white : colors.gray[300])};
   text-transform: capitalize;
   text-align: center;
-  border-bottom: ${(props) => (props.selected ? `4px solid ${colors.blue[500]}` : 'none')};
+  border-bottom: ${(props) => (props.selected ? `4px solid ${colors.blue[500]}` : '4px solid transparent')};
 
-  :hover {
+  :hover,
+  :focus {
+    border-bottom: ${(props) => (props.selected ? `4px solid ${colors.blue[500]}` : `4px solid ${colors.gray[300]}`)};
     cursor: pointer;
   }
 

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels