Przeglądaj źródła

Channels in your language (#1092)

* Add languages query

* Add small size for Select component

* Add Channels in your language section to Browse channels page
Rafał Pawłow 3 lat temu
rodzic
commit
856ac06674

+ 1 - 0
src/api/hooks/index.ts

@@ -9,3 +9,4 @@ export * from './video'
 export * from './membership'
 export * from './queryNode'
 export * from './workers'
+export * from './languages'

+ 13 - 0
src/api/hooks/languages.ts

@@ -0,0 +1,13 @@
+import { QueryHookOptions } from '@apollo/client'
+
+import { GetLanguagesQuery, GetLanguagesQueryVariables, useGetLanguagesQuery } from '@/api/queries'
+
+type LanguagesOpts = QueryHookOptions<GetLanguagesQuery>
+export const useLanguages = (variables?: GetLanguagesQueryVariables, opts?: LanguagesOpts) => {
+  const { data, ...rest } = useGetLanguagesQuery({ ...opts, variables })
+
+  return {
+    languages: data?.languages,
+    ...rest,
+  }
+}

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

@@ -12,11 +12,6 @@ export type Scalars = {
   DateTime: Date
 }
 
-export type Language = {
-  __typename?: 'Language'
-  iso: Scalars['String']
-}
-
 export type VideoCategory = {
   __typename?: 'VideoCategory'
   id: Scalars['ID']
@@ -126,6 +121,7 @@ export type ChannelWhereInput = {
   isCensored_eq?: Maybe<Scalars['Boolean']>
   coverPhotoAvailability_eq?: Maybe<AssetAvailability>
   avatarPhotoAvailability_eq?: Maybe<AssetAvailability>
+  languageId_eq?: Maybe<Scalars['ID']>
 }
 
 export type ChannelWhereUniqueInput = {
@@ -259,6 +255,35 @@ export type ProcessorState = {
   chainHead: Scalars['Float']
 }
 
+export type Language = {
+  __typename?: 'Language'
+  id: Scalars['ID']
+  iso: Scalars['String']
+}
+
+export type LanguageWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  iso_eq?: Maybe<Scalars['String']>
+  iso_contains?: Maybe<Scalars['String']>
+  iso_startsWith?: Maybe<Scalars['String']>
+  iso_endsWith?: Maybe<Scalars['String']>
+  iso_in?: Maybe<Array<Scalars['String']>>
+}
+
+export enum LanguageOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  IsoAsc = 'iso_ASC',
+  IsoDesc = 'iso_DESC',
+  CreatedInBlockAsc = 'createdInBlock_ASC',
+  CreatedInBlockDesc = 'createdInBlock_DESC',
+}
+
 export type Query = {
   __typename?: 'Query'
   /** Get follows counts for a list of channels */
@@ -274,6 +299,7 @@ export type Query = {
   channelViews?: Maybe<EntityViewsInfo>
   channels: Array<Channel>
   channelsConnection: ChannelConnection
+  languages: Array<Language>
   membershipByUniqueInput?: Maybe<Membership>
   memberships: Array<Membership>
   /** Get list of channels with most views in given period */
@@ -328,6 +354,13 @@ export type QueryChannelsConnectionArgs = {
   orderBy?: Maybe<Array<ChannelOrderByInput>>
 }
 
+export type QueryLanguagesArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<LanguageWhereInput>
+  orderBy?: Maybe<Array<LanguageOrderByInput>>
+}
+
 export type QueryMembershipByUniqueInputArgs = {
   where: MembershipWhereUniqueInput
 }

+ 11 - 3
src/api/queries/__generated__/channels.generated.tsx

@@ -24,7 +24,7 @@ export type AllChannelFieldsFragment = {
   isCensored: boolean
   coverPhotoUrls: Array<string>
   coverPhotoAvailability: Types.AssetAvailability
-  language?: Types.Maybe<{ __typename?: 'Language'; iso: string }>
+  language?: Types.Maybe<{ __typename?: 'Language'; id: string; iso: string }>
   ownerMember?: Types.Maybe<{ __typename?: 'Membership'; id: string; handle: string; avatarUri?: Types.Maybe<string> }>
   coverPhotoDataObject?: Types.Maybe<{ __typename?: 'DataObject' } & DataObjectFieldsFragment>
 } & BasicChannelFieldsFragment
@@ -71,6 +71,7 @@ export type GetChannelsConnectionQueryVariables = Types.Exact<{
   first?: Types.Maybe<Types.Scalars['Int']>
   after?: Types.Maybe<Types.Scalars['String']>
   where?: Types.Maybe<Types.ChannelWhereInput>
+  orderBy?: Types.Maybe<Array<Types.ChannelOrderByInput> | Types.ChannelOrderByInput>
 }>
 
 export type GetChannelsConnectionQuery = {
@@ -173,6 +174,7 @@ export const AllChannelFieldsFragmentDoc = gql`
     isPublic
     isCensored
     language {
+      id
       iso
     }
     ownerMember {
@@ -341,8 +343,13 @@ export type GetChannelsQueryHookResult = ReturnType<typeof useGetChannelsQuery>
 export type GetChannelsLazyQueryHookResult = ReturnType<typeof useGetChannelsLazyQuery>
 export type GetChannelsQueryResult = Apollo.QueryResult<GetChannelsQuery, GetChannelsQueryVariables>
 export const GetChannelsConnectionDocument = gql`
-  query GetChannelsConnection($first: Int, $after: String, $where: ChannelWhereInput) {
-    channelsConnection(first: $first, after: $after, where: $where, orderBy: [createdAt_DESC]) {
+  query GetChannelsConnection(
+    $first: Int
+    $after: String
+    $where: ChannelWhereInput
+    $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]
+  ) {
+    channelsConnection(first: $first, after: $after, where: $where, orderBy: $orderBy) {
       edges {
         cursor
         node {
@@ -374,6 +381,7 @@ export const GetChannelsConnectionDocument = gql`
  *      first: // value for 'first'
  *      after: // value for 'after'
  *      where: // value for 'where'
+ *      orderBy: // value for 'orderBy'
  *   },
  * });
  */

+ 58 - 0
src/api/queries/__generated__/languages.generated.tsx

@@ -0,0 +1,58 @@
+import { gql } from '@apollo/client'
+import * as Apollo from '@apollo/client'
+
+import * as Types from './baseTypes.generated'
+
+export type GetLanguagesQueryVariables = Types.Exact<{
+  offset?: Types.Maybe<Types.Scalars['Int']>
+  limit?: Types.Maybe<Types.Scalars['Int']>
+  where?: Types.Maybe<Types.LanguageWhereInput>
+  orderBy?: Types.Maybe<Array<Types.LanguageOrderByInput> | Types.LanguageOrderByInput>
+}>
+
+export type GetLanguagesQuery = {
+  __typename?: 'Query'
+  languages: Array<{ __typename?: 'Language'; id: string; iso: string }>
+}
+
+export const GetLanguagesDocument = gql`
+  query GetLanguages($offset: Int, $limit: Int, $where: LanguageWhereInput, $orderBy: [LanguageOrderByInput!]) {
+    languages(offset: $offset, limit: $limit, where: $where, orderBy: $orderBy) {
+      id
+      iso
+    }
+  }
+`
+
+/**
+ * __useGetLanguagesQuery__
+ *
+ * To run a query within a React component, call `useGetLanguagesQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetLanguagesQuery` 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 } = useGetLanguagesQuery({
+ *   variables: {
+ *      offset: // value for 'offset'
+ *      limit: // value for 'limit'
+ *      where: // value for 'where'
+ *      orderBy: // value for 'orderBy'
+ *   },
+ * });
+ */
+export function useGetLanguagesQuery(
+  baseOptions?: Apollo.QueryHookOptions<GetLanguagesQuery, GetLanguagesQueryVariables>
+) {
+  return Apollo.useQuery<GetLanguagesQuery, GetLanguagesQueryVariables>(GetLanguagesDocument, baseOptions)
+}
+export function useGetLanguagesLazyQuery(
+  baseOptions?: Apollo.LazyQueryHookOptions<GetLanguagesQuery, GetLanguagesQueryVariables>
+) {
+  return Apollo.useLazyQuery<GetLanguagesQuery, GetLanguagesQueryVariables>(GetLanguagesDocument, baseOptions)
+}
+export type GetLanguagesQueryHookResult = ReturnType<typeof useGetLanguagesQuery>
+export type GetLanguagesLazyQueryHookResult = ReturnType<typeof useGetLanguagesLazyQuery>
+export type GetLanguagesQueryResult = Apollo.QueryResult<GetLanguagesQuery, GetLanguagesQueryVariables>

+ 8 - 2
src/api/queries/channels.graphql

@@ -18,6 +18,7 @@ fragment AllChannelFields on Channel {
   isPublic
   isCensored
   language {
+    id
     iso
   }
   ownerMember {
@@ -56,8 +57,13 @@ query GetChannels($offset: Int, $limit: Int, $where: ChannelWhereInput) {
   }
 }
 
-query GetChannelsConnection($first: Int, $after: String, $where: ChannelWhereInput) {
-  channelsConnection(first: $first, after: $after, where: $where, orderBy: [createdAt_DESC]) {
+query GetChannelsConnection(
+  $first: Int
+  $after: String
+  $where: ChannelWhereInput
+  $orderBy: [ChannelOrderByInput!] = [createdAt_DESC]
+) {
+  channelsConnection(first: $first, after: $after, where: $where, orderBy: $orderBy) {
     edges {
       cursor
       node {

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

@@ -4,3 +4,4 @@ export * from './__generated__/channels.generated'
 export * from './__generated__/search.generated'
 export * from './__generated__/videos.generated'
 export * from './__generated__/memberships.generated'
+export * from './__generated__/languages.generated'

+ 6 - 0
src/api/queries/languages.graphql

@@ -0,0 +1,6 @@
+query GetLanguages($offset: Int, $limit: Int, $where: LanguageWhereInput, $orderBy: [LanguageOrderByInput!]) {
+  languages(offset: $offset, limit: $limit, where: $where, orderBy: $orderBy) {
+    id
+    iso
+  }
+}

+ 31 - 4
src/api/schemas/extendedQueryNode.graphql

@@ -1,9 +1,5 @@
 scalar DateTime
 
-type Language {
-  iso: String!
-}
-
 type VideoCategory {
   id: ID!
   name: String
@@ -121,6 +117,7 @@ input ChannelWhereInput {
   isCensored_eq: Boolean
   coverPhotoAvailability_eq: AssetAvailability
   avatarPhotoAvailability_eq: AssetAvailability
+  languageId_eq: ID
 }
 
 input ChannelWhereUniqueInput {
@@ -256,6 +253,34 @@ type ProcessorState {
   chainHead: Float!
 }
 
+type Language {
+  id: ID!
+  iso: String!
+}
+
+input LanguageWhereInput {
+  id_eq: ID
+  id_in: [ID!]
+  iso_eq: String
+  iso_contains: String
+  iso_startsWith: String
+  iso_endsWith: String
+  iso_in: [String!]
+}
+
+enum LanguageOrderByInput {
+  createdAt_ASC
+  createdAt_DESC
+  updatedAt_ASC
+  updatedAt_DESC
+  deletedAt_ASC
+  deletedAt_DESC
+  iso_ASC
+  iso_DESC
+  createdInBlock_ASC
+  createdInBlock_DESC
+}
+
 type Query {
   # Lookup a membership by its ID
   membershipByUniqueInput(where: MembershipWhereUniqueInput!): Membership
@@ -295,6 +320,8 @@ type Query {
 
   # Free text search across videos and channels
   search(limit: Int, text: String!, whereVideo: VideoWhereInput, whereChannel: ChannelWhereInput): [SearchFTSOutput!]!
+
+  languages(offset: Int, limit: Int, where: LanguageWhereInput, orderBy: [LanguageOrderByInput!]): [Language!]!
 }
 
 type Subscription {

+ 5 - 1
src/components/ChannelCard.tsx

@@ -10,9 +10,10 @@ type ChannelCardProps = {
   id?: string
   className?: string
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
+  variant?: 'primary' | 'secondary'
 }
 
-export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick }) => {
+export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick, variant = 'primary' }) => {
   const { channel, loading } = useChannel(id ?? '', { fetchPolicy: 'cache-first', skip: !id })
   const { url } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
   const { videoCount } = useChannelVideoCount(id ?? '', undefined, {
@@ -30,6 +31,9 @@ export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick
       loading={isLoading}
       onClick={onClick}
       assetUrl={url}
+      variant={variant}
+      follows={channel?.follows}
+      channelId={id}
     />
   )
 }

+ 53 - 0
src/components/ChannelWithVideos.tsx

@@ -0,0 +1,53 @@
+import React, { FC, Fragment, useState } from 'react'
+
+import { GetVideosConnectionDocument, GetVideosConnectionQuery, GetVideosConnectionQueryVariables } from '@/api/queries'
+import { ChannelPreview } from '@/components/ChannelPreview'
+import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
+import { VideoPreview } from '@/components/VideoPreview'
+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) => (
+        <VideoPreview id={video.id} key={`channels-with-videos-${idx}`} showChannel />
+      ))}
+    </>
+  )
+
+  return (
+    <>
+      <ChannelPreview id={channelId} variant="secondary" />
+      <Grid onResize={(sizes) => setVideosPerRow(sizes.length)}>{gridContent}</Grid>
+    </>
+  )
+}

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

@@ -0,0 +1,134 @@
+import React, { FC, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
+
+import { useLanguages } from '@/api/hooks'
+import {
+  ChannelOrderByInput,
+  ChannelWhereInput,
+  GetChannelsConnectionDocument,
+  GetChannelsConnectionQuery,
+  GetChannelsConnectionQueryVariables,
+} from '@/api/queries'
+import { ChannelWithVideos } from '@/components'
+import { useInfiniteGrid } from '@/components/InfiniteGrids/useInfiniteGrid'
+import { languages } from '@/config/languages'
+import { LoadMoreButton, Select, Text } from '@/shared/components'
+
+import { LanguageSelectWrapper, LoadMoreButtonWrapper, Separator, TitleWrapper } from './InfiniteGrid.style'
+
+type InfiniteChannelWithVideosGridProps = {
+  onDemand?: boolean
+  title?: string
+  skipCount?: number
+  isReady?: boolean
+  className?: string
+  languageSelector?: boolean
+}
+
+const INITIAL_ROWS = 3
+const INITIAL_CHANNELS_PER_ROW = 1
+
+export const InfiniteChannelWithVideosGrid: FC<InfiniteChannelWithVideosGridProps> = ({
+  onDemand = false,
+  title,
+  skipCount = 0,
+  isReady = true,
+  className,
+  languageSelector,
+}) => {
+  const [selectedLanguage, setSelectedLanguage] = useState<string | null | undefined>(null)
+  const [targetRowsCount, setTargetRowsCount] = useState(INITIAL_ROWS)
+  const { languages: queryNodeLanguages, loading: languagesLoading } = useLanguages()
+  const fetchMore = useCallback(() => {
+    setTargetRowsCount((prevState) => prevState + 3)
+  }, [])
+
+  const queryVariables: { where: ChannelWhereInput } = {
+    where: {
+      ...(selectedLanguage ? { languageId_eq: selectedLanguage } : {}),
+      isPublic_eq: true,
+      isCensored_eq: false,
+    },
+  }
+
+  const { displayedItems, placeholdersCount, loading, error, totalCount } = useInfiniteGrid<
+    GetChannelsConnectionQuery,
+    GetChannelsConnectionQuery['channelsConnection'],
+    GetChannelsConnectionQueryVariables
+  >({
+    query: GetChannelsConnectionDocument,
+    isReady: isReady && !!selectedLanguage,
+    skipCount,
+    orderBy: ChannelOrderByInput.CreatedAtAsc,
+    queryVariables,
+    targetRowsCount,
+    dataAccessor: (rawData) => rawData?.channelsConnection,
+    itemsPerRow: INITIAL_CHANNELS_PER_ROW,
+  })
+
+  if (error) {
+    throw error
+  }
+
+  const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))
+  const shouldShowLoadMoreButton = onDemand && !loading && displayedItems.length < totalCount
+
+  const itemsToShow = [...displayedItems, ...placeholderItems]
+
+  const mappedLanguages = useMemo(() => {
+    const mergedLanguages: Array<{ name: string; value: string }> = []
+    if (queryNodeLanguages) {
+      queryNodeLanguages.forEach((language) => {
+        const matchedLanguage = languages.find((item) => item.value === language.iso)
+        if (matchedLanguage) {
+          mergedLanguages.push({
+            name: matchedLanguage?.name,
+            value: language.id,
+          })
+        }
+      })
+    }
+    return mergedLanguages
+  }, [queryNodeLanguages])
+
+  // Set initial language
+  useEffect(() => {
+    if (mappedLanguages.length) {
+      setSelectedLanguage(mappedLanguages.find((item) => item.name === 'English')?.value)
+    }
+  }, [mappedLanguages])
+
+  const onSelectLanguage = (value?: string | null) => {
+    setTargetRowsCount(INITIAL_ROWS)
+    setSelectedLanguage(value)
+  }
+
+  return (
+    <section className={className}>
+      <TitleWrapper>
+        {title && <Text variant="h5">{title}</Text>}
+        {languageSelector && (
+          <LanguageSelectWrapper>
+            <Select
+              items={mappedLanguages || []}
+              disabled={languagesLoading}
+              value={selectedLanguage}
+              size="small"
+              onChange={onSelectLanguage}
+            />
+          </LanguageSelectWrapper>
+        )}
+      </TitleWrapper>
+      {itemsToShow.map((channel, idx) => (
+        <Fragment key={`channels-with-videos-${idx}`}>
+          <ChannelWithVideos channelId={channel.id} />
+          {idx + 1 < itemsToShow.length && <Separator />}
+        </Fragment>
+      ))}
+      {shouldShowLoadMoreButton && (
+        <LoadMoreButtonWrapper>
+          <LoadMoreButton onClick={fetchMore} label="Show more channels" />
+        </LoadMoreButtonWrapper>
+      )}
+    </section>
+  )
+}

+ 17 - 1
src/components/InfiniteGrids/InfiniteGrid.style.ts

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled'
 
 import { SkeletonLoader, Text } from '@/shared/components'
-import { sizes } from '@/shared/theme'
+import { colors, sizes } from '@/shared/theme'
 
 export const Title = styled(Text)`
   margin-bottom: ${sizes(4)};
@@ -14,3 +14,19 @@ export const StyledSkeletonLoader = styled(SkeletonLoader)`
 export const LoadMoreButtonWrapper = styled.div`
   margin-top: ${sizes(10)};
 `
+
+export const TitleWrapper = styled.div`
+  display: flex;
+`
+
+export const LanguageSelectWrapper = styled.div`
+  margin-left: ${sizes(6)};
+  width: ${sizes(34)};
+`
+
+export const Separator = styled.div`
+  height: 1px;
+  background-color: ${colors.gray[700]};
+  margin-top: ${sizes(12)};
+  margin-bottom: ${sizes(24)};
+`

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

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

+ 17 - 3
src/components/InfiniteGrids/useInfiniteGrid.ts

@@ -1,8 +1,10 @@
 import { ApolloError, useQuery } from '@apollo/client'
 import { TypedDocumentNode } from '@graphql-typed-document-node/core'
 import { DocumentNode } from 'graphql'
-import { debounce } from 'lodash'
-import { useEffect } from 'react'
+import { debounce, isEqual } from 'lodash'
+import { useEffect, useRef } from 'react'
+
+import { ChannelOrderByInput } from '@/api/queries'
 
 export type PaginatedData<T> = {
   edges: {
@@ -39,6 +41,7 @@ type UseInfiniteGridParams<TRawData, TPaginatedData extends PaginatedData<unknow
   queryVariables: TArgs
   onDemand?: boolean
   onScrollToBottom?: () => void
+  orderBy?: ChannelOrderByInput
 }
 
 type UseInfiniteGridReturn<TPaginatedData extends PaginatedData<unknown>> = {
@@ -65,15 +68,19 @@ export const useInfiniteGrid = <
   onError,
   queryVariables,
   onDemand,
+  orderBy = ChannelOrderByInput.CreatedAtDesc,
 }: UseInfiniteGridParams<TRawData, TPaginatedData, TArgs>): UseInfiniteGridReturn<TPaginatedData> => {
   const targetDisplayedItemsCount = targetRowsCount * itemsPerRow
   const targetLoadedItemsCount = targetDisplayedItemsCount + skipCount
 
-  const { loading, data: rawData, error, fetchMore } = useQuery<TRawData, TArgs>(query, {
+  const queryVariablesRef = useRef(queryVariables)
+
+  const { loading, data: rawData, error, fetchMore, refetch } = useQuery<TRawData, TArgs>(query, {
     notifyOnNetworkStatusChange: true,
     skip: !isReady,
     variables: {
       ...queryVariables,
+      orderBy,
       first: targetLoadedItemsCount,
     },
     onError,
@@ -112,6 +119,13 @@ export const useInfiniteGrid = <
     isReady,
   ])
 
+  useEffect(() => {
+    if (!isEqual(queryVariablesRef.current, queryVariables)) {
+      queryVariablesRef.current = queryVariables
+      refetch()
+    }
+  }, [queryVariables, refetch])
+
   // handle scroll to bottom
   useEffect(() => {
     if (onDemand) {

+ 1 - 0
src/components/index.ts

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

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

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

+ 60 - 12
src/shared/components/ChannelCardBase/ChannelCardBase.style.tsx

@@ -2,17 +2,66 @@ import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 import { Link } from 'react-router-dom'
 
-import { colors, sizes, transitions, typography } from '../../theme'
+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`
-  display: flex;
+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')};
@@ -21,9 +70,10 @@ export const OuterContainer = styled.article`
 
 type InnerContainerProps = {
   animated: boolean
+  variant?: string
 }
-const hoverTransition = ({ animated }: InnerContainerProps) =>
-  animated
+const hoverTransition = ({ animated, variant }: InnerContainerProps) =>
+  animated && variant !== 'secondary'
     ? css`
         transition: all 0.4s ${transitions.easing};
 
@@ -36,13 +86,9 @@ const hoverTransition = ({ animated }: InnerContainerProps) =>
     : null
 
 export const InnerContainer = styled.div<InnerContainerProps>`
-  background-color: ${colors.gray[800]};
   color: ${colors.gray[300]};
-  width: calc(156px + calc(2 * ${containerPadding}));
   padding: 0 ${containerPadding} ${sizes(3)} ${containerPadding};
   display: flex;
-  flex-direction: column;
-  align-items: center;
   border: 1px solid transparent;
   ${hoverTransition}
 `
@@ -55,9 +101,7 @@ export const Anchor = styled(Link)`
 export const Info = styled.div`
   display: flex;
   flex-direction: column;
-  align-items: center;
   text-align: center;
-  margin-top: ${sizes(3)};
   padding: 0 ${sizes(1)};
   max-width: 100%;
 `
@@ -70,8 +114,8 @@ export const AvatarContainer = styled.div`
   width: 100%;
   height: 156px;
   position: relative;
-  margin-top: -${imageTopOverflow};
   z-index: 2;
+  flex-shrink: 0;
 `
 
 export const TextBase = styled(Text)`
@@ -94,3 +138,7 @@ export const StyledAvatar = styled(Avatar)`
     font-size: ${typography.sizes.h2};
   }
 `
+
+export const FollowButton = styled(Button)`
+  margin-top: ${sizes(2)};
+`

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

@@ -1,11 +1,15 @@
-import React from 'react'
+import React, { useEffect, useState } from 'react'
 import { CSSTransition, SwitchTransition } from 'react-transition-group'
 
+import { useFollowChannel, useUnfollowChannel } from '@/api/hooks'
+import { usePersonalDataStore } from '@/providers'
 import { transitions } from '@/shared/theme'
+import { Logger } from '@/utils/logger'
 
 import {
   Anchor,
   AvatarContainer,
+  FollowButton,
   Info,
   InnerContainer,
   OuterContainer,
@@ -25,6 +29,9 @@ export type ChannelCardBaseProps = {
   className?: string
   loading?: boolean
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
+  variant?: 'primary' | 'secondary'
+  follows?: number | null
+  channelId?: string
 }
 
 export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
@@ -35,8 +42,16 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
   channelHref,
   className,
   onClick,
+  variant,
+  follows,
+  channelId,
 }) => {
-  const isAnimated = !loading && !!channelHref
+  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)
@@ -46,8 +61,36 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
       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) {
+        Logger.warn('Failed to update Channel following', { error })
+      }
+    }
+  }
+
+  const followersLabel = follows && follows >= 1 ? `${follows} Follower` : `${follows} Followers`
+
   return (
-    <OuterContainer className={className} onClick={handleClick}>
+    <OuterContainer className={className} onClick={handleClick} variant={variant}>
       <Anchor to={channelHref ?? ''} onClick={handleAnchorClick}>
         <SwitchTransition>
           <CSSTransition
@@ -74,10 +117,18 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
                       timeout={parseInt(transitions.timings.loading) * 0.5}
                       classNames={transitions.names.fade}
                     >
-                      <VideoCount variant="subtitle2">{`${videoCount ?? ''} Uploads`}</VideoCount>
+                      <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>

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

@@ -6,9 +6,10 @@ import { SvgGlyphChevronDown } from '@/shared/icons'
 
 type LoadMoreButtonProps = {
   onClick: (event: MouseEvent<HTMLButtonElement>) => void
+  label?: string
 }
 
-export const LoadMoreButton: FC<LoadMoreButtonProps> = ({ onClick }) => (
+export const LoadMoreButton: FC<LoadMoreButtonProps> = ({ onClick, label = 'Show more videos' }) => (
   <LoadMore
     variant="secondary"
     size="large"
@@ -17,7 +18,7 @@ export const LoadMoreButton: FC<LoadMoreButtonProps> = ({ onClick }) => (
     iconPlacement="right"
     icon={<SvgGlyphChevronDown width={12} height={12} />}
   >
-    Show more videos
+    {label}
   </LoadMore>
 )
 

+ 4 - 0
src/shared/components/Select/Select.stories.tsx

@@ -20,6 +20,10 @@ export default {
     error: '',
     helperText: '',
     warning: '',
+    fullWidth: {
+      control: { type: 'select', options: ['regular', 'small'] },
+      defaultValue: 'regular',
+    },
   },
 } as Meta
 

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

@@ -1,8 +1,11 @@
+import { css } from '@emotion/react'
 import styled from '@emotion/styled'
 
 import { SvgGlyphInfo } from '@/shared/icons'
 import { colors, sizes, transitions, typography } from '@/shared/theme'
 
+import { SelectSizes } from '.'
+
 export const SelectWrapper = styled.div`
   width: 100%;
   position: relative;
@@ -12,6 +15,7 @@ type SelectButtonProps = {
   filled?: boolean
   error?: boolean
   disabled?: boolean
+  size?: SelectSizes
 }
 
 export const SelectButton = styled.button<SelectButtonProps>`
@@ -19,7 +23,6 @@ export const SelectButton = styled.button<SelectButtonProps>`
   width: 100%;
   overflow: hidden;
   text-overflow: ellipsis;
-  min-height: 42px;
   border: none;
   background: none;
   color: ${({ filled }) => (filled ? colors.gray[50] : colors.gray[300])};
@@ -28,6 +31,21 @@ export const SelectButton = styled.button<SelectButtonProps>`
   justify-content: space-between;
   align-items: center;
 
+  ${({ size }) => {
+    switch (size) {
+      case 'regular':
+        return css`
+          min-height: 42px;
+        `
+      case 'small':
+        return css`
+          min-height: ${sizes(8)};
+          font-size: 14px !important;
+          padding: 0 ${sizes(4)} !important;
+        `
+    }
+  }}
+
   svg {
     transition: all ${transitions.timings.regular} ${transitions.easing};
     transform: rotate(${({ isOpen }) => (isOpen ? 180 : 0)}deg);

+ 5 - 0
src/shared/components/Select/Select.tsx

@@ -8,6 +8,8 @@ import { SelectButton, SelectMenu, SelectOption, SelectWrapper, StyledSvgGlyphIn
 import { InputBase, InputBaseProps, LabelText } from '../InputBase'
 import { Tooltip } from '../Tooltip'
 
+export type SelectSizes = 'regular' | 'small'
+
 export type SelectItem<T = string> = {
   value: T
   name: string
@@ -21,6 +23,7 @@ export type SelectProps<T = string> = {
   items: SelectItem<T>[]
   placeholder?: string
   containerRef?: Ref<HTMLDivElement>
+  size?: SelectSizes
 } & InputBaseProps
 
 // don't use React.FC so we can use a generic type on a component
@@ -33,6 +36,7 @@ export const Select = <T,>({
   disabled,
   onChange,
   containerRef,
+  size = 'regular',
   ...inputBaseProps
 }: SelectProps<T>) => {
   const itemsValues = items.map((item) => item.value)
@@ -67,6 +71,7 @@ export const Select = <T,>({
           type="button"
           {...getToggleButtonProps()}
           tabIndex={disabled ? -1 : 0}
+          size={size}
         >
           {selectedItem?.name || placeholder}
           <SvgGlyphChevronDown />

+ 15 - 9
src/views/viewer/ChannelsView/ChannelsView.tsx

@@ -1,17 +1,23 @@
+import styled from '@emotion/styled'
 import React from 'react'
 
-import { BackgroundPattern, InfiniteChannelGrid } from '@/components'
-import { transitions } from '@/shared/theme'
-import { Header, StyledViewWrapper } from '@/views/viewer/VideosView/VideosView.style'
+import { InfiniteChannelWithVideosGrid, ViewWrapper } from '@/components'
+import { Text } from '@/shared/components'
+import { sizes } from '@/shared/theme'
 
-export const ChannelsView: React.FC = () => {
+export const ChannelsView = () => {
   return (
     <StyledViewWrapper>
-      <BackgroundPattern />
-      <Header variant="hero" className={transitions.names.slide}>
-        Channels
-      </Header>
-      <InfiniteChannelGrid className={transitions.names.slide} />
+      <Header variant="h2">Browse channels</Header>
+      <InfiniteChannelWithVideosGrid title="Channels in your language:" languageSelector onDemand />
     </StyledViewWrapper>
   )
 }
+
+const Header = styled(Text)`
+  margin: ${sizes(16)} 0;
+`
+
+const StyledViewWrapper = styled(ViewWrapper)`
+  padding-bottom: ${sizes(10)};
+`

+ 15 - 2
src/views/viewer/NewView/NewView.tsx

@@ -1,5 +1,18 @@
-import React, { FC } from 'react'
+import styled from '@emotion/styled'
+import React from 'react'
 
 import { ViewWrapper } from '@/components'
+import { Text } from '@/shared/components'
+import { sizes } from '@/shared/theme'
 
-export const NewView: FC = () => <ViewWrapper>New & Noteworthy</ViewWrapper>
+export const NewView = () => {
+  return (
+    <ViewWrapper>
+      <Header variant="h2">New & Noteworthy</Header>
+    </ViewWrapper>
+  )
+}
+
+const Header = styled(Text)`
+  margin-top: ${sizes(16)};
+`

+ 15 - 2
src/views/viewer/PopularView/PopularView.tsx

@@ -1,5 +1,18 @@
-import React, { FC } from 'react'
+import styled from '@emotion/styled'
+import React from 'react'
 
 import { ViewWrapper } from '@/components'
+import { Text } from '@/shared/components'
+import { sizes } from '@/shared/theme'
 
-export const PopularView: FC = () => <ViewWrapper>Popular</ViewWrapper>
+export const PopularView = () => {
+  return (
+    <ViewWrapper>
+      <Header variant="h2">Popular</Header>
+    </ViewWrapper>
+  )
+}
+
+const Header = styled(Text)`
+  margin-top: ${sizes(16)};
+`