ソースを参照

Add Top 10 channels section (#1120)

* create top 10 channels

* refactor TempChannel

* refactor ChannelCard component

* improvements, update ChannelCardBase

* CR fixes

* remove useMemo

* remove ChannelCard from ChannelWithVideos

* remove channel card variant

* fix skeleton width, fix layout shift

* move ChannelWithVideos to new directory

* replace SkeletonLoaders

* refactor loadings
Bartosz Dryl 3 年 前
コミット
60b4df823e
28 ファイル変更739 行追加428 行削除
  1. 54 7
      src/api/hooks/channel.ts
  2. 36 6
      src/api/queries/__generated__/baseTypes.generated.ts
  3. 122 0
      src/api/queries/__generated__/channels.generated.tsx
  4. 14 0
      src/api/queries/channels.graphql
  5. 31 10
      src/api/schemas/orion.graphql
  6. 20 20
      src/components/ChannelCard.tsx
  7. 37 20
      src/components/ChannelGallery.tsx
  8. 0 53
      src/components/ChannelWithVideos.tsx
  9. 42 0
      src/components/ChannelWithVideos/ChannelWithVideos.style.ts
  10. 95 0
      src/components/ChannelWithVideos/ChannelWithVideos.tsx
  11. 1 0
      src/components/ChannelWithVideos/index.ts
  12. 1 1
      src/components/InfiniteGrids/InfiniteChannelWithVideosGrid.tsx
  13. 22 0
      src/components/TopTenChannels.tsx
  14. 0 2
      src/components/VideoGallery.tsx
  15. 1 0
      src/components/index.ts
  16. 1 0
      src/hooks/index.ts
  17. 31 0
      src/hooks/useHandleFollowChannel.tsx
  18. 13 1
      src/shared/components/Avatar/Avatar.style.tsx
  19. 3 10
      src/shared/components/Carousel/Carousel.style.ts
  20. 1 5
      src/shared/components/Carousel/Carousel.tsx
  21. 0 27
      src/shared/components/ChannelCardBase/ChannelCard.stories.tsx
  22. 40 0
      src/shared/components/ChannelCardBase/ChannelCardBase.stories.tsx
  23. 109 0
      src/shared/components/ChannelCardBase/ChannelCardBase.style.ts
  24. 0 144
      src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx
  25. 55 117
      src/shared/components/ChannelCardBase/ChannelCardBase.tsx
  26. 2 2
      src/shared/components/Tooltip/Tooltip.stories.tsx
  27. 1 1
      src/shared/components/index.ts
  28. 7 2
      src/views/viewer/ChannelsView/ChannelsView.tsx

+ 54 - 7
src/api/hooks/channel.ts

@@ -8,6 +8,8 @@ import {
   GetChannelQuery,
   GetChannelsQuery,
   GetChannelsQueryVariables,
+  GetMostFollowedChannelsAllTimeQuery,
+  GetMostFollowedChannelsAllTimeQueryVariables,
   GetMostViewedChannelsAllTimeQuery,
   GetMostViewedChannelsAllTimeQueryVariables,
   GetMostViewedChannelsQuery,
@@ -18,6 +20,7 @@ import {
   useGetBasicChannelQuery,
   useGetChannelQuery,
   useGetChannelsQuery,
+  useGetMostFollowedChannelsAllTimeQuery,
   useGetMostViewedChannelsAllTimeQuery,
   useGetMostViewedChannelsQuery,
   useGetVideoCountQuery,
@@ -164,8 +167,57 @@ export const useMostViewedChannels = (
     { skip: !mostViewedChannelsIds }
   )
 
+  const sortedChannels = useMemo(() => {
+    if (channels) {
+      return [...channels].sort((a, b) => (b.follows || 0) - (a.follows || 0))
+    }
+    return null
+  }, [channels])
+
   return {
-    channels,
+    channels: sortedChannels,
+    ...rest,
+  }
+}
+
+type MostFollowedChannelsAllTimeOpts = QueryHookOptions<GetMostFollowedChannelsAllTimeQuery>
+export const useMostFollowedChannelsAllTimeIds = (
+  variables?: GetMostFollowedChannelsAllTimeQueryVariables,
+  opts?: MostFollowedChannelsAllTimeOpts
+) => {
+  const { data, ...rest } = useGetMostFollowedChannelsAllTimeQuery({ ...opts, variables })
+  return {
+    mostFollowedChannelsAllTime: data?.mostFollowedChannelsAllTime,
+    ...rest,
+  }
+}
+
+export const useMostFollowedChannelsAllTime = (
+  variables?: GetMostFollowedChannelsAllTimeQueryVariables,
+  opts?: MostPopularChannelsOpts
+) => {
+  const { mostFollowedChannelsAllTime } = useMostFollowedChannelsAllTimeIds(variables, opts)
+
+  const mostFollowedChannelsAllTimeIds = mostFollowedChannelsAllTime?.map((item) => item.id)
+
+  const { channels, ...rest } = useChannels(
+    {
+      where: {
+        id_in: mostFollowedChannelsAllTimeIds,
+      },
+    },
+    { skip: !mostFollowedChannelsAllTimeIds }
+  )
+
+  const sortedChannels = useMemo(() => {
+    if (channels) {
+      return [...channels].sort((a, b) => (b.follows || 0) - (a.follows || 0))
+    }
+    return null
+  }, [channels])
+
+  return {
+    channels: sortedChannels,
     ...rest,
   }
 }
@@ -188,12 +240,7 @@ export const useMostViewedChannelsAllTime = (
 ) => {
   const { mostViewedChannelsAllTime } = useMostViewedChannelsAllTimeIds(variables, opts)
 
-  const mostViewedChannelsIds = useMemo(() => {
-    if (mostViewedChannelsAllTime) {
-      return mostViewedChannelsAllTime.map((item) => item.id)
-    }
-    return null
-  }, [mostViewedChannelsAllTime])
+  const mostViewedChannelsIds = mostViewedChannelsAllTime?.map((item) => item.id)
 
   const { channels, ...rest } = useChannels(
     {

+ 36 - 6
src/api/queries/__generated__/baseTypes.generated.ts

@@ -302,13 +302,24 @@ export type Query = {
   languages: Array<Language>
   membershipByUniqueInput?: Maybe<Membership>
   memberships: Array<Membership>
-  /** Get list of channels with most views in given period */
+  /** Get list of most followed channels */
+  mostFollowedChannels: Array<ChannelFollowsInfo>
+  /** Get list of most followed channels of all time */
+  mostFollowedChannelsAllTime?: Maybe<Array<ChannelFollowsInfo>>
+  /**
+   * Get list of channels with most views in given period
+   * Get most viewed list of categories
+   */
+  mostViewedCategories?: Maybe<Array<EntityViewsInfo>>
+  /** Get most viewed list of categories of all time */
+  mostViewedCategoriesAllTime?: Maybe<Array<EntityViewsInfo>>
+  /** Get most viewed list of channels */
   mostViewedChannels?: Maybe<Array<EntityViewsInfo>>
   /** Get list of channels with most views in given period */
   mostViewedChannelsAllTime?: Maybe<Array<EntityViewsInfo>>
-  /** Get list of most viewed videos in given period */
+  /** Get most viewed list of videos */
   mostViewedVideos?: Maybe<Array<EntityViewsInfo>>
-  /** Get list of most viewed videos in given period */
+  /** Get most viewed list of videos of all time */
   mostViewedVideosAllTime?: Maybe<Array<EntityViewsInfo>>
   search: Array<SearchFtsOutput>
   videoByUniqueInput?: Maybe<Video>
@@ -374,18 +385,36 @@ export type QueryMembershipsArgs = {
   where: MembershipWhereInput
 }
 
-export type QueryMostViewedChannelsArgs = {
+export type QueryMostFollowedChannelsArgs = {
+  limit?: Maybe<Scalars['Int']>
   period: Scalars['Int']
+}
+
+export type QueryMostFollowedChannelsAllTimeArgs = {
+  limit: Scalars['Int']
+}
+
+export type QueryMostViewedCategoriesArgs = {
   limit?: Maybe<Scalars['Int']>
+  period: Scalars['Int']
 }
 
-export type QueryMostViewedChannelsAllTimeArgs = {
+export type QueryMostViewedCategoriesAllTimeArgs = {
+  limit: Scalars['Int']
+}
+
+export type QueryMostViewedChannelsArgs = {
   limit?: Maybe<Scalars['Int']>
+  period: Scalars['Int']
+}
+
+export type QueryMostViewedChannelsAllTimeArgs = {
+  limit: Scalars['Int']
 }
 
 export type QueryMostViewedVideosArgs = {
-  period: Scalars['Int']
   limit?: Maybe<Scalars['Int']>
+  period: Scalars['Int']
 }
 
 export type QueryMostViewedVideosAllTimeArgs = {
@@ -459,6 +488,7 @@ export type Mutation = {
 }
 
 export type MutationAddVideoViewArgs = {
+  categoryId?: Maybe<Scalars['ID']>
   channelId: Scalars['ID']
   videoId: Scalars['ID']
 }

+ 122 - 0
src/api/queries/__generated__/channels.generated.tsx

@@ -161,6 +161,25 @@ export type GetMostViewedChannelsAllTimeQuery = {
   mostViewedChannelsAllTime?: Types.Maybe<Array<{ __typename?: 'EntityViewsInfo'; id: string; views: number }>>
 }
 
+export type GetMostFollowedChannelsQueryVariables = Types.Exact<{
+  followedWithinDays: Types.Scalars['Int']
+  limit?: Types.Maybe<Types.Scalars['Int']>
+}>
+
+export type GetMostFollowedChannelsQuery = {
+  __typename?: 'Query'
+  mostFollowedChannels: Array<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>
+}
+
+export type GetMostFollowedChannelsAllTimeQueryVariables = Types.Exact<{
+  limit: Types.Scalars['Int']
+}>
+
+export type GetMostFollowedChannelsAllTimeQuery = {
+  __typename?: 'Query'
+  mostFollowedChannelsAllTime?: Types.Maybe<Array<{ __typename?: 'ChannelFollowsInfo'; id: string; follows: number }>>
+}
+
 export const BasicChannelFieldsFragmentDoc = gql`
   fragment BasicChannelFields on Channel {
     id
@@ -774,3 +793,106 @@ export type GetMostViewedChannelsAllTimeQueryResult = Apollo.QueryResult<
   GetMostViewedChannelsAllTimeQuery,
   GetMostViewedChannelsAllTimeQueryVariables
 >
+export const GetMostFollowedChannelsDocument = gql`
+  query GetMostFollowedChannels($followedWithinDays: Int!, $limit: Int) {
+    mostFollowedChannels(period: $followedWithinDays, limit: $limit) {
+      id
+      follows
+    }
+  }
+`
+
+/**
+ * __useGetMostFollowedChannelsQuery__
+ *
+ * To run a query within a React component, call `useGetMostFollowedChannelsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostFollowedChannelsQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostFollowedChannelsQuery({
+ *   variables: {
+ *      followedWithinDays: // value for 'followedWithinDays'
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostFollowedChannelsQuery(
+  baseOptions: Apollo.QueryHookOptions<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>
+) {
+  return Apollo.useQuery<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>(
+    GetMostFollowedChannelsDocument,
+    baseOptions
+  )
+}
+export function useGetMostFollowedChannelsLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetMostFollowedChannelsQuery, GetMostFollowedChannelsQueryVariables>(
+    GetMostFollowedChannelsDocument,
+    baseOptions
+  )
+}
+export type GetMostFollowedChannelsQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsQuery>
+export type GetMostFollowedChannelsLazyQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsLazyQuery>
+export type GetMostFollowedChannelsQueryResult = Apollo.QueryResult<
+  GetMostFollowedChannelsQuery,
+  GetMostFollowedChannelsQueryVariables
+>
+export const GetMostFollowedChannelsAllTimeDocument = gql`
+  query GetMostFollowedChannelsAllTime($limit: Int!) {
+    mostFollowedChannelsAllTime(limit: $limit) {
+      id
+      follows
+    }
+  }
+`
+
+/**
+ * __useGetMostFollowedChannelsAllTimeQuery__
+ *
+ * To run a query within a React component, call `useGetMostFollowedChannelsAllTimeQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMostFollowedChannelsAllTimeQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMostFollowedChannelsAllTimeQuery({
+ *   variables: {
+ *      limit: // value for 'limit'
+ *   },
+ * });
+ */
+export function useGetMostFollowedChannelsAllTimeQuery(
+  baseOptions: Apollo.QueryHookOptions<
+    GetMostFollowedChannelsAllTimeQuery,
+    GetMostFollowedChannelsAllTimeQueryVariables
+  >
+) {
+  return Apollo.useQuery<GetMostFollowedChannelsAllTimeQuery, GetMostFollowedChannelsAllTimeQueryVariables>(
+    GetMostFollowedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export function useGetMostFollowedChannelsAllTimeLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<
+    GetMostFollowedChannelsAllTimeQuery,
+    GetMostFollowedChannelsAllTimeQueryVariables
+  >
+) {
+  return Apollo.useLazyQuery<GetMostFollowedChannelsAllTimeQuery, GetMostFollowedChannelsAllTimeQueryVariables>(
+    GetMostFollowedChannelsAllTimeDocument,
+    baseOptions
+  )
+}
+export type GetMostFollowedChannelsAllTimeQueryHookResult = ReturnType<typeof useGetMostFollowedChannelsAllTimeQuery>
+export type GetMostFollowedChannelsAllTimeLazyQueryHookResult = ReturnType<
+  typeof useGetMostFollowedChannelsAllTimeLazyQuery
+>
+export type GetMostFollowedChannelsAllTimeQueryResult = Apollo.QueryResult<
+  GetMostFollowedChannelsAllTimeQuery,
+  GetMostFollowedChannelsAllTimeQueryVariables
+>

+ 14 - 0
src/api/queries/channels.graphql

@@ -137,3 +137,17 @@ query GetMostViewedChannelsAllTime($limit: Int!) {
     views
   }
 }
+
+query GetMostFollowedChannels($followedWithinDays: Int!, $limit: Int) {
+  mostFollowedChannels(period: $followedWithinDays, limit: $limit) {
+    id
+    follows
+  }
+}
+
+query GetMostFollowedChannelsAllTime($limit: Int!) {
+  mostFollowedChannelsAllTime(limit: $limit) {
+    id
+    follows
+  }
+}

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

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

+ 20 - 20
src/components/ChannelCard.tsx

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

+ 37 - 20
src/components/ChannelGallery.tsx

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

+ 0 - 53
src/components/ChannelWithVideos.tsx

@@ -1,53 +0,0 @@
-import React, { FC, Fragment, useState } from 'react'
-
-import { GetVideosConnectionDocument, GetVideosConnectionQuery, GetVideosConnectionQueryVariables } from '@/api/queries'
-import { ChannelCard } from '@/components/ChannelCard'
-import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
-import { VideoTile } from '@/components/VideoTile'
-import { Grid } from '@/shared/components'
-
-type ChannelWithVideosProps = {
-  channelId?: string
-}
-
-const INITIAL_VIDEOS_PER_ROW = 4
-const INITAL_ROWS = 1
-
-export const ChannelWithVideos: FC<ChannelWithVideosProps> = ({ channelId }) => {
-  const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
-  const { displayedItems, placeholdersCount } = useInfiniteGrid<
-    GetVideosConnectionQuery,
-    GetVideosConnectionQuery['videosConnection'],
-    GetVideosConnectionQueryVariables
-  >({
-    query: GetVideosConnectionDocument,
-    isReady: !!channelId,
-    skipCount: 0,
-    queryVariables: {
-      where: {
-        channelId_eq: channelId,
-        isPublic_eq: true,
-        isCensored_eq: false,
-      },
-    },
-    targetRowsCount: INITAL_ROWS,
-    dataAccessor: (rawData) => rawData?.videosConnection,
-    itemsPerRow: videosPerRow,
-  })
-
-  const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))
-  const gridContent = (
-    <>
-      {[...displayedItems, ...placeholderItems]?.map((video, idx) => (
-        <VideoTile id={video.id} key={`channels-with-videos-${idx}`} showChannel />
-      ))}
-    </>
-  )
-
-  return (
-    <>
-      <ChannelCard id={channelId} variant="secondary" />
-      <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
-    </>
-  )
-}

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

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

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

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

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

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

+ 1 - 1
src/components/InfiniteGrids/InfiniteChannelWithVideosGrid.tsx

@@ -9,7 +9,7 @@ import {
   GetChannelsConnectionQueryVariables,
   VideoEdge,
 } from '@/api/queries'
-import { ChannelWithVideos } from '@/components'
+import { ChannelWithVideos } from '@/components/ChannelWithVideos'
 import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
 import { languages } from '@/config/languages'
 import { GridHeadingContainer, LoadMoreButton, Select } from '@/shared/components'

+ 22 - 0
src/components/TopTenChannels.tsx

@@ -0,0 +1,22 @@
+import styled from '@emotion/styled'
+import React from 'react'
+
+import { useMostFollowedChannelsAllTime } from '@/api/hooks'
+import { sizes } from '@/shared/theme'
+
+import { ChannelGallery } from './ChannelGallery'
+
+export const TopTenChannels = () => {
+  const { channels, loading } = useMostFollowedChannelsAllTime({ limit: 10 })
+
+  const isLoading = loading || channels === null
+  return (
+    <Wrapper>
+      <ChannelGallery hasRanking channels={channels} loading={isLoading} title="Top 10 Channels" />
+    </Wrapper>
+  )
+}
+
+const Wrapper = styled.div`
+  margin-top: ${sizes(18)};
+`

+ 0 - 2
src/components/VideoGallery.tsx

@@ -87,8 +87,6 @@ export const VideoGallery: React.FC<VideoGalleryProps> = ({
   return (
     <Gallery
       title={title}
-      paddingLeft={sizes(2, true)}
-      paddingTop={sizes(2, true)}
       responsive={breakpoints}
       itemWidth={MIN_VIDEO_PREVIEW_WIDTH}
       dotsVisible

+ 1 - 0
src/components/index.ts

@@ -25,4 +25,5 @@ export * from './StudioEntrypoint'
 export * from './PrivateRoute'
 export * from './OfficialJoystreamUpdate'
 export * from './TopTenThisWeek'
+export * from './TopTenChannels'
 export * from './ChannelWithVideos'

+ 1 - 0
src/hooks/index.ts

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

+ 31 - 0
src/hooks/useHandleFollowChannel.tsx

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

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

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

+ 3 - 10
src/shared/components/Carousel/Carousel.style.ts

@@ -10,11 +10,6 @@ export const Container = styled.div`
   position: relative;
 `
 
-type HasPadding = {
-  paddingLeft: number
-  paddingTop: number
-}
-
 export const Arrow = styled(IconButton)`
   display: none;
   z-index: ${zIndex.nearOverlay};
@@ -46,11 +41,9 @@ export const Arrow = styled(IconButton)`
   }
 `
 
-export const GliderContainer = styled.div<HasPadding>`
-  padding-left: ${(props) => props.paddingLeft}px;
-  padding-top: ${(props) => props.paddingTop}px;
-  margin-left: ${(props) => -props.paddingLeft}px;
-  margin-top: ${(props) => -props.paddingTop}px;
+export const GliderContainer = styled.div`
+  padding-left: ${sizes(2)};
+  padding-top: ${sizes(2)};
 `
 
 export const Track = styled.div`

+ 1 - 5
src/shared/components/Carousel/Carousel.tsx

@@ -5,8 +5,6 @@ import { Container, Dots, GliderContainer, Track } from './Carousel.style'
 import { GliderProps, useGlider } from '../Glider'
 
 export type CarouselProps = {
-  paddingLeft?: number
-  paddingTop?: number
   className?: string
   arrowPosition?: number
   dotsVisible?: boolean
@@ -22,8 +20,6 @@ export const Carousel = forwardRef<
   (
     {
       children,
-      paddingLeft = 0,
-      paddingTop = 0,
       className = '',
       arrowPosition,
       slidesToShow = 'auto',
@@ -57,7 +53,7 @@ export const Carousel = forwardRef<
 
     return (
       <Container {...getContainerProps({ className })}>
-        <GliderContainer {...getGliderProps()} paddingLeft={paddingLeft} paddingTop={paddingTop} ref={gliderRef}>
+        <GliderContainer {...getGliderProps()} ref={gliderRef}>
           <Track {...getTrackProps()}>{children}</Track>
         </GliderContainer>
         {dotsVisible && <Dots {...getDotsProps()} ref={dotsRef} />}

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

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

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

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

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

@@ -0,0 +1,109 @@
+import styled from '@emotion/styled'
+import { Link } from 'react-router-dom'
+
+import { colors, media, sizes, transitions } from '@/shared/theme'
+
+import { Avatar } from '../Avatar'
+import { Button } from '../Button'
+import { Text } from '../Text'
+
+type ChannelCardWrapperProps = {
+  hasRanking?: boolean
+}
+
+export const ChannelCardWrapper = styled.div<ChannelCardWrapperProps>`
+  position: relative;
+  display: flex;
+  justify-content: ${({ hasRanking }) => (hasRanking ? 'flex-end' : 'unset')};
+  width: 100%;
+
+  ${() => ChannelCardArticle} {
+    width: ${({ hasRanking }) => (hasRanking ? '78%' : '100%')};
+  }
+`
+
+export const ChannelCardArticle = styled.article`
+  position: relative;
+
+  :hover:not(:active) {
+    ${() => ChannelCardAnchor} {
+      transform: translate(-${sizes(2)}, -${sizes(2)});
+      box-shadow: ${sizes(2)} ${sizes(2)} 0 ${colors.blue['500']};
+    }
+  }
+`
+
+export const ChannelCardAnchor = styled(Link)`
+  text-decoration: none;
+  align-items: center;
+  transition: transform, box-shadow;
+  transition-duration: ${transitions.timings.regular};
+  transition-timing-function: ${transitions.easing};
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+  background-color: ${colors.gray[900]};
+  padding: ${sizes(4)} 0;
+`
+
+export const StyledAvatar = styled(Avatar)`
+  margin-bottom: ${sizes(4)};
+`
+
+export const InfoWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`
+
+export const ChannelTitle = styled(Text)`
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const ChannelFollows = styled(Text)`
+  margin-top: ${sizes(1)};
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+
+export const FollowButton = styled(Button)`
+  margin-top: ${sizes(4)};
+`
+
+export const RankingNumber = styled.span`
+  position: absolute;
+  top: -8px;
+  left: -48px;
+  z-index: -1;
+  height: 100%;
+  color: black;
+  font-weight: 700;
+  font-size: 100px;
+  -webkit-text-stroke-width: 4px;
+  -webkit-text-stroke-color: ${colors.gray[500]};
+  font-family: 'PxGrotesk', sans-serif;
+  letter-spacing: -0.17em;
+  display: flex;
+  align-items: center;
+
+  ${media.large} {
+    align-items: flex-start;
+    font-size: 160px;
+    left: -77px;
+  }
+
+  ${media.xlarge} {
+    left: -75px;
+    font-size: 150px;
+  }
+
+  ${media.xxlarge} {
+    left: -88px;
+    font-size: 180px;
+  }
+`

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

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

+ 55 - 117
src/shared/components/ChannelCardBase/ChannelCardBase.tsx

@@ -1,139 +1,77 @@
-import React, { useEffect, useState } from 'react'
-import { CSSTransition, SwitchTransition } from 'react-transition-group'
+import React from 'react'
 
-import { useFollowChannel, useUnfollowChannel } from '@/api/hooks'
-import { usePersonalDataStore } from '@/providers'
-import { transitions } from '@/shared/theme'
-import { SentryLogger } from '@/utils/logs'
+import { absoluteRoutes } from '@/config/routes'
+import { formatNumberShort } from '@/utils/number'
 
 import {
-  Anchor,
-  AvatarContainer,
+  ChannelCardAnchor,
+  ChannelCardArticle,
+  ChannelCardWrapper,
+  ChannelFollows,
+  ChannelTitle,
   FollowButton,
-  Info,
-  InnerContainer,
-  OuterContainer,
+  InfoWrapper,
+  RankingNumber,
   StyledAvatar,
-  TextBase,
-  VideoCount,
-  VideoCountContainer,
 } from './ChannelCardBase.style'
 
 import { SkeletonLoader } from '../SkeletonLoader'
 
 export type ChannelCardBaseProps = {
-  assetUrl?: string | null
+  id?: string | null
+  rankingNumber?: number
+  isLoading?: boolean
   title?: string | null
-  videoCount?: number
-  channelHref?: string
-  className?: string
-  loading?: boolean
-  onClick?: (e: React.MouseEvent<HTMLElement>) => void
-  variant?: 'primary' | 'secondary'
   follows?: number | null
-  channelId?: string
+  avatarUrl?: string | null
+  isFollowing?: boolean
+  onFollow?: (event: React.MouseEvent) => void
+  className?: string
+  onClick?: () => void
 }
 
 export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
-  assetUrl,
+  id,
+  rankingNumber,
+  isLoading,
   title,
-  videoCount,
-  loading = true,
-  channelHref,
+  follows,
+  avatarUrl,
+  isFollowing,
+  onFollow,
   className,
   onClick,
-  variant,
-  follows,
-  channelId,
 }) => {
-  const { followChannel } = useFollowChannel()
-  const { unfollowChannel } = useUnfollowChannel()
-  const [isFollowing, setFollowing] = useState<boolean>()
-  const followedChannels = usePersonalDataStore((state) => state.followedChannels)
-  const updateChannelFollowing = usePersonalDataStore((state) => state.actions.updateChannelFollowing)
-  const isAnimated = !loading && !!channelHref && variant === 'primary'
-  const handleClick = (e: React.MouseEvent<HTMLElement>) => {
-    if (!onClick) return
-    onClick(e)
-  }
-  const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
-    if (!channelHref) {
-      e.preventDefault()
-    }
-  }
-
-  useEffect(() => {
-    const isFollowing = followedChannels.some((channel) => channel.id === channelId)
-    setFollowing(isFollowing)
-  }, [followedChannels, channelId])
-
-  const onFollowClick = (event: React.MouseEvent<HTMLElement>) => {
-    event.stopPropagation()
-    event.preventDefault()
-    if (channelId) {
-      try {
-        if (isFollowing) {
-          updateChannelFollowing(channelId, false)
-          unfollowChannel(channelId)
-          setFollowing(false)
-        } else {
-          updateChannelFollowing(channelId, true)
-          followChannel(channelId)
-          setFollowing(true)
-        }
-      } catch (error) {
-        SentryLogger.error('Failed to update channel following', 'ChannelView', error, { channel: { id: channelId } })
-      }
-    }
-  }
-
-  const followersLabel = follows && follows >= 1 ? `${follows} Follower` : `${follows} Followers`
-
+  const hasRanking = !!rankingNumber
   return (
-    <OuterContainer className={className} onClick={handleClick} variant={variant}>
-      <Anchor to={channelHref ?? ''} onClick={handleAnchorClick}>
-        <SwitchTransition>
-          <CSSTransition
-            key={loading ? 'placeholder' : 'content'}
-            timeout={parseInt(transitions.timings.loading) * 0.75}
-            classNames={transitions.names.fade}
-          >
-            <InnerContainer animated={isAnimated}>
-              <AvatarContainer>
-                {loading ? <SkeletonLoader rounded /> : <StyledAvatar assetUrl={assetUrl} />}
-              </AvatarContainer>
-              <Info>
-                {loading ? (
-                  <SkeletonLoader width="140px" height="16px" />
-                ) : (
-                  <TextBase variant="h6">{title || '\u00A0'}</TextBase>
-                )}
-                <VideoCountContainer>
-                  {loading ? (
-                    <SkeletonLoader width="140px" height="16px" />
-                  ) : (
-                    <CSSTransition
-                      in={!!videoCount}
-                      timeout={parseInt(transitions.timings.loading) * 0.5}
-                      classNames={transitions.names.fade}
-                    >
-                      <VideoCount variant="subtitle2">
-                        {videoCount && variant === 'primary' ? `${videoCount} Uploads` : null}
-                        {follows && variant === 'secondary' ? followersLabel : '0 Followers'}
-                      </VideoCount>
-                    </CSSTransition>
-                  )}
-                </VideoCountContainer>
-                {variant === 'secondary' && (
-                  <FollowButton variant="secondary" onClick={onFollowClick}>
-                    {isFollowing ? 'Unfollow' : 'Follow'}
-                  </FollowButton>
-                )}
-              </Info>
-            </InnerContainer>
-          </CSSTransition>
-        </SwitchTransition>
-      </Anchor>
-    </OuterContainer>
+    <ChannelCardWrapper className={className} hasRanking={hasRanking}>
+      <ChannelCardArticle>
+        {hasRanking && <RankingNumber>{rankingNumber}</RankingNumber>}
+        <ChannelCardAnchor onClick={onClick} to={absoluteRoutes.viewer.channel(id || '')}>
+          <StyledAvatar size="channel-card" loading={isLoading} assetUrl={avatarUrl} />
+          <InfoWrapper>
+            {isLoading ? (
+              <SkeletonLoader width="100px" height="20px" bottomSpace="4px" />
+            ) : (
+              <ChannelTitle variant="h6">{title}</ChannelTitle>
+            )}
+            {isLoading ? (
+              <SkeletonLoader width="70px" height="20px" bottomSpace="16px" />
+            ) : (
+              <ChannelFollows variant="body2" secondary>
+                {formatNumberShort(follows || 0)} followers
+              </ChannelFollows>
+            )}
+            {isLoading ? (
+              <SkeletonLoader width="60px" height="30px" />
+            ) : (
+              <FollowButton variant="secondary" size="small" onClick={onFollow}>
+                {isFollowing ? 'Unfollow' : 'Follow'}
+              </FollowButton>
+            )}
+          </InfoWrapper>
+        </ChannelCardAnchor>
+      </ChannelCardArticle>
+    </ChannelCardWrapper>
   )
 }

+ 2 - 2
src/shared/components/Tooltip/Tooltip.stories.tsx

@@ -47,8 +47,8 @@ const ChannelCardTooltip: Story<TooltipProps> = (args) => (
       <Tooltip {...args}>
         <ChannelCardBase
           title="Lorem"
-          assetUrl="https://eu-central-1.linodeobjects.com/atlas-assets/channel-avatars/2.jpg"
-          loading={false}
+          avatarUrl="https://eu-central-1.linodeobjects.com/atlas-assets/channel-avatars/2.jpg"
+          isLoading={false}
         />
       </Tooltip>
     </div>

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

@@ -7,7 +7,6 @@ export * from './RadioButton'
 export * from './Text'
 export * from './VideoTileBase'
 export * from './VideoPlayer'
-export * from './ChannelCardBase'
 export * from './HamburgerButton'
 export * from './Gallery'
 export * from './ChannelAvatar'
@@ -48,3 +47,4 @@ export * from './LayoutGrid/LayoutGrid'
 export * from './LoadMoreButton'
 export * from './CallToActionButton'
 export * from './GridHeading'
+export * from './ChannelCardBase'

+ 7 - 2
src/views/viewer/ChannelsView/ChannelsView.tsx

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled'
 import React from 'react'
 
-import { InfiniteChannelWithVideosGrid, ViewWrapper } from '@/components'
+import { InfiniteChannelWithVideosGrid, TopTenChannels, ViewWrapper } from '@/components'
 import { absoluteRoutes } from '@/config/routes'
 import { CallToActionButton, CallToActionWrapper, Text } from '@/shared/components'
 import { SvgNavHome, SvgNavNew, SvgNavPopular } from '@/shared/icons'
@@ -11,7 +11,8 @@ export const ChannelsView = () => {
   return (
     <StyledViewWrapper>
       <Header variant="h2">Browse channels</Header>
-      <InfiniteChannelWithVideosGrid title="Channels in your language:" languageSelector onDemand />
+      <TopTenChannels />
+      <StyledInfiniteChannelWithVideosGrid title="Channels in your language:" languageSelector onDemand />
       <CallToActionWrapper>
         <CallToActionButton
           label="Popular on Joystream"
@@ -43,3 +44,7 @@ const Header = styled(Text)`
 const StyledViewWrapper = styled(ViewWrapper)`
   padding-bottom: ${sizes(10)};
 `
+
+const StyledInfiniteChannelWithVideosGrid = styled(InfiniteChannelWithVideosGrid)`
+  margin-top: ${sizes(36)};
+`