Procházet zdrojové kódy

Channel view layout (#976)

* fix weird charactr bug on channels with no uploads

* layout wip

* Update src/components/LimitedWidthContainer.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* Update src/views/viewer/ChannelView/ChannelView.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* Update src/views/viewer/ChannelView/ChannelView.style.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* why is lint not doing this automatically

* wip

* Tabs

* about wip

* about wip

* wip

* layout

* Update src/views/viewer/ChannelView/ChannelAbout.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* cr

* cr

* cr

* trying to get channel views but failed

* channel views

* Revert "trying to get channel views but failed" and
"channel views"

This reverts commit 8da6e6fa17bd0bc614a8b989e76e3735d993ef7b.
bd61c620a876568c08a5370893901dee9ec85537.

* cr

* fix weird charactr bug on channels with no uploads

* layout wip

* Update src/components/LimitedWidthContainer.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* Update src/views/viewer/ChannelView/ChannelView.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* Update src/views/viewer/ChannelView/ChannelView.style.tsx

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>

* why is lint not doing this automatically

* wip

* Tabs

* about wip

* about wip

* wip

* layout

* cr

* cr

* cr

* trying to get channel views but failed

* channel views

* Revert "trying to get channel views but failed" and
"channel views"

This reverts commit 8da6e6fa17bd0bc614a8b989e76e3735d993ef7b.
bd61c620a876568c08a5370893901dee9ec85537.

* cr

* cr

* visual cr

* information

Co-authored-by: Bartosz Dryl <drylbartosz@gmail.com>
Diego Cardenas před 3 roky
rodič
revize
7424d224a0

+ 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}
 `

+ 1 - 1
src/components/index.ts

@@ -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 },
+]

+ 14 - 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'
+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':

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

@@ -74,7 +74,7 @@ export const ChannelCardBase: React.FC<ChannelCardBaseProps> = ({
                       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>

+ 2 - 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));
@@ -34,98 +28,17 @@ export const Media = styled.div`
   z-index: ${zIndex.background};
 `
 
-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 +57,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 />

+ 80 - 77
src/views/studio/CreateEditChannelView/CreateEditChannelView.tsx

@@ -5,7 +5,12 @@ import { CSSTransition } from 'react-transition-group'
 
 import { useChannel } from '@/api/hooks'
 import { AssetAvailability } from '@/api/queries'
-import { ImageCropDialog, ImageCropDialogImperativeHandle, ImageCropDialogProps, StudioContainer } from '@/components'
+import {
+  ImageCropDialog,
+  ImageCropDialogImperativeHandle,
+  ImageCropDialogProps,
+  LimitedWidthContainer,
+} from '@/components'
 import { languages } from '@/config/languages'
 import { absoluteRoutes } from '@/config/routes'
 import { useDisplayDataLostWarning } from '@/hooks'
@@ -39,7 +44,7 @@ import { requiredValidation, textFieldValidation } from '@/utils/formValidationO
 import { computeFileHash } from '@/utils/hashing'
 import { Logger } from '@/utils/logger'
 import { formatNumberShort } from '@/utils/number'
-import { Header, SubTitleSkeletonLoader, TitleSkeletonLoader } from '@/views/viewer/ChannelView/ChannelView.style'
+import { SubTitleSkeletonLoader, TitleSkeletonLoader } from '@/views/viewer/ChannelView/ChannelView.style'
 
 import {
   InnerFormContainer,
@@ -358,99 +363,97 @@ export const CreateEditChannelView: React.FC<CreateEditChannelViewProps> = ({ ne
 
   return (
     <form onSubmit={handleSubmit}>
-      <Header>
+      <Controller
+        name="cover"
+        control={control}
+        render={() => (
+          <>
+            <ChannelCover
+              assetUrl={loading ? null : coverAsset?.url}
+              hasCoverUploadFailed={hasCoverUploadFailed}
+              onCoverEditClick={() => coverDialogRef.current?.open()}
+              editable
+              disabled={loading}
+            />
+            <ImageCropDialog
+              imageType="cover"
+              onConfirm={handleCoverChange}
+              onError={() =>
+                displaySnackbar({
+                  title: 'Cannot load the image. Choose another.',
+                  iconType: 'error',
+                })
+              }
+              ref={coverDialogRef}
+            />
+          </>
+        )}
+      />
+
+      <StyledTitleSection className={transitions.names.slide}>
         <Controller
-          name="cover"
+          name="avatar"
           control={control}
           render={() => (
             <>
-              <ChannelCover
-                assetUrl={loading ? null : coverAsset?.url}
-                hasCoverUploadFailed={hasCoverUploadFailed}
-                onCoverEditClick={() => coverDialogRef.current?.open()}
+              <StyledAvatar
+                assetUrl={avatarAsset?.url}
+                hasAvatarUploadFailed={hasAvatarUploadFailed}
+                size="fill"
+                onEditClick={() => avatarDialogRef.current?.open()}
                 editable
-                disabled={loading}
+                loading={loading}
               />
               <ImageCropDialog
-                imageType="cover"
-                onConfirm={handleCoverChange}
+                imageType="avatar"
+                onConfirm={handleAvatarChange}
                 onError={() =>
                   displaySnackbar({
                     title: 'Cannot load the image. Choose another.',
                     iconType: 'error',
                   })
                 }
-                ref={coverDialogRef}
+                ref={avatarDialogRef}
               />
             </>
           )}
         />
 
-        <StyledTitleSection className={transitions.names.slide}>
-          <Controller
-            name="avatar"
-            control={control}
-            render={() => (
-              <>
-                <StyledAvatar
-                  assetUrl={avatarAsset?.url}
-                  hasAvatarUploadFailed={hasAvatarUploadFailed}
-                  size="fill"
-                  onEditClick={() => avatarDialogRef.current?.open()}
-                  editable
-                  loading={loading}
-                />
-                <ImageCropDialog
-                  imageType="avatar"
-                  onConfirm={handleAvatarChange}
-                  onError={() =>
-                    displaySnackbar({
-                      title: 'Cannot load the image. Choose another.',
-                      iconType: 'error',
-                    })
-                  }
-                  ref={avatarDialogRef}
-                />
-              </>
-            )}
-          />
-
-          <TitleContainer>
-            {!loading || newChannel ? (
-              <>
-                <Controller
-                  name="title"
-                  control={control}
-                  rules={textFieldValidation({ name: 'Channel name', minLength: 3, maxLength: 40, required: true })}
-                  render={({ field: { ref, value, onChange } }) => (
-                    <Tooltip text="Click to edit channel title">
-                      <StyledTitleArea
-                        ref={ref}
-                        placeholder="Channel title"
-                        value={value}
-                        onChange={(e) => {
-                          onChange(e.currentTarget.value)
-                        }}
-                        error={!!errors.title}
-                        helperText={errors.title?.message}
-                      />
-                    </Tooltip>
-                  )}
-                />
-                {!newChannel && (
-                  <StyledSubTitle>{channel?.follows ? formatNumberShort(channel.follows) : 0} Followers</StyledSubTitle>
+        <TitleContainer>
+          {!loading || newChannel ? (
+            <>
+              <Controller
+                name="title"
+                control={control}
+                rules={textFieldValidation({ name: 'Channel name', minLength: 3, maxLength: 40, required: true })}
+                render={({ field: { ref, value, onChange } }) => (
+                  <Tooltip text="Click to edit channel title">
+                    <StyledTitleArea
+                      ref={ref}
+                      placeholder="Channel title"
+                      value={value}
+                      onChange={(e) => {
+                        onChange(e.currentTarget.value)
+                      }}
+                      error={!!errors.title}
+                      helperText={errors.title?.message}
+                    />
+                  </Tooltip>
                 )}
-              </>
-            ) : (
-              <>
-                <TitleSkeletonLoader />
-                <SubTitleSkeletonLoader />
-              </>
-            )}
-          </TitleContainer>
-        </StyledTitleSection>
-      </Header>
-      <StudioContainer>
+              />
+              {!newChannel && (
+                <StyledSubTitle>{channel?.follows ? formatNumberShort(channel.follows) : 0} Followers</StyledSubTitle>
+              )}
+            </>
+          ) : (
+            <>
+              <TitleSkeletonLoader />
+              <SubTitleSkeletonLoader />
+            </>
+          )}
+        </TitleContainer>
+      </StyledTitleSection>
+      <LimitedWidthContainer>
         <InnerFormContainer>
           <FormField title="Description">
             <Tooltip text="Click to edit channel description">
@@ -523,7 +526,7 @@ export const CreateEditChannelView: React.FC<CreateEditChannelViewProps> = ({ ne
             />
           </CSSTransition>
         </InnerFormContainer>
-      </StudioContainer>
+      </LimitedWidthContainer>
     </form>
   )
 }

+ 2 - 2
src/views/studio/CreateMemberView/CreateMemberView.style.ts

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled'
 
-import { StudioContainer } from '@/components'
+import { LimitedWidthContainer } from '@/components'
 import { Avatar, Button, Text, TextField } from '@/shared/components'
 import { media, sizes } from '@/shared/theme'
 
@@ -20,7 +20,7 @@ export const SubTitle = styled(Text)`
   margin-top: ${sizes(4)};
 `
 
-export const Wrapper = styled(StudioContainer)`
+export const Wrapper = styled(LimitedWidthContainer)`
   display: flex;
   flex-direction: column;
   margin-left: auto;

+ 2 - 2
src/views/studio/EditVideoSheet/EditVideoForm/EditVideoForm.style.ts

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled'
 
-import { studioContainerStyle } from '@/components/StudioContainer'
+import { limitedWidthContainerStyle } from '@/components/LimitedWidthContainer'
 import { Button, TitleArea } from '@/shared/components'
 import { colors, media, sizes } from '@/shared/theme'
 
@@ -37,7 +37,7 @@ export const FormWrapper = styled.form`
     padding: ${sizes(8)} 0 0 0;
   }
 
-  ${studioContainerStyle};
+  ${limitedWidthContainerStyle};
 `
 
 export const InputsContainer = styled.div`

+ 2 - 2
src/views/studio/MyUploadsView/MyUploadsView.style.tsx

@@ -1,10 +1,10 @@
 import styled from '@emotion/styled'
 
-import { StudioContainer } from '@/components'
+import { LimitedWidthContainer } from '@/components'
 import { Text } from '@/shared/components'
 import { sizes } from '@/shared/theme'
 
-export const UploadsContainer = styled(StudioContainer)`
+export const UploadsContainer = styled(LimitedWidthContainer)`
   padding-bottom: 120px;
 `
 

+ 5 - 11
src/views/studio/MyVideosView/MyVideosView.tsx

@@ -3,8 +3,9 @@ import { useNavigate } from 'react-router-dom'
 
 import { useVideosConnection } from '@/api/hooks'
 import { VideoOrderByInput } from '@/api/queries'
-import { StudioContainer, VideoTilePublisher } from '@/components'
+import { LimitedWidthContainer, VideoTilePublisher } from '@/components'
 import { absoluteRoutes } from '@/config/routes'
+import { SORT_OPTIONS } from '@/config/sorting'
 import { useDeleteVideo } from '@/hooks'
 import {
   chanelUnseenDraftsSelector,
@@ -27,10 +28,6 @@ import {
 } from './MyVideos.styles'
 
 const TABS = ['All Videos', 'Public', 'Drafts', 'Unlisted'] as const
-const SORT_OPTIONS = [
-  { name: 'Newest first', value: VideoOrderByInput.CreatedAtDesc },
-  { name: 'Oldest first', value: VideoOrderByInput.CreatedAtAsc },
-]
 
 const INITIAL_VIDEOS_PER_ROW = 4
 const ROWS_AMOUNT = 4
@@ -44,9 +41,7 @@ export const MyVideosView = () => {
   const { setSheetState, videoTabs, addVideoTab, setSelectedVideoTabIdx, removeVideoTab } = useEditVideoSheet()
   const { displaySnackbar, updateSnackbar } = useSnackbar()
   const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
-  const [sortVideosBy, setSortVideosBy] = useState<typeof SORT_OPTIONS[number]['value'] | undefined>(
-    VideoOrderByInput.CreatedAtDesc
-  )
+  const [sortVideosBy, setSortVideosBy] = useState<VideoOrderByInput | undefined>(VideoOrderByInput.CreatedAtDesc)
   const [tabIdToRemoveViaSnackbar, setTabIdToRemoveViaSnackbar] = useState<string>()
   const videosPerPage = ROWS_AMOUNT * videosPerRow
 
@@ -58,7 +53,6 @@ export const MyVideosView = () => {
   const removeDraftNotificationsCount = useRef(0)
   const addToTabNotificationsCount = useRef(0)
 
-  // Drafts calls can run into race conditions
   const { currentPage, setCurrentPage } = usePagination(currentVideosTab)
   const { activeChannelId } = useAuthorizedUser()
   const { removeDrafts, markAllDraftsAsSeenForChannel } = useDraftStore(({ actions }) => actions)
@@ -256,7 +250,7 @@ export const MyVideosView = () => {
 
   const mappedTabs = TABS.map((tab) => ({ name: tab, badgeNumber: tab === 'Drafts' ? unseenDrafts.length : 0 }))
   return (
-    <StudioContainer>
+    <LimitedWidthContainer>
       <ViewContainer>
         <Text variant="h2">My videos</Text>
         {hasNoVideos ? (
@@ -314,7 +308,7 @@ export const MyVideosView = () => {
           </>
         )}
       </ViewContainer>
-    </StudioContainer>
+    </LimitedWidthContainer>
   )
 }
 

+ 2 - 2
src/views/studio/SignInJoinView/SignInMainView/SignInMainView.style.ts

@@ -1,12 +1,12 @@
 import styled from '@emotion/styled'
 import { Link } from 'react-router-dom'
 
-import { StudioContainer } from '@/components'
+import { LimitedWidthContainer } from '@/components'
 import { Button, Text } from '@/shared/components'
 import { SvgSigninIllustration } from '@/shared/illustrations'
 import { colors, media, sizes } from '@/shared/theme'
 
-export const StyledContainer = styled(StudioContainer)`
+export const StyledContainer = styled(LimitedWidthContainer)`
   margin-top: 64px;
   display: flex;
   flex-direction: column;

+ 2 - 2
src/views/studio/SignInJoinView/SignInProcessView/SignInProcessView.style.ts

@@ -1,11 +1,11 @@
 import styled from '@emotion/styled'
 
-import { StudioContainer } from '@/components'
+import { LimitedWidthContainer } from '@/components'
 import { Button, Text } from '@/shared/components'
 import { SvgCoinsIllustration } from '@/shared/illustrations'
 import { colors, media, sizes } from '@/shared/theme'
 
-export const StyledStudioContainer = styled(StudioContainer)`
+export const StyledStudioContainer = styled(LimitedWidthContainer)`
   display: flex;
   margin-top: 64px;
   justify-content: space-between;

+ 2 - 2
src/views/studio/SignInView/SignInView.style.ts

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled'
 
-import { StudioContainer } from '@/components'
+import { LimitedWidthContainer } from '@/components'
 import { Avatar, Button, Text } from '@/shared/components'
 import { colors, sizes, transitions } from '@/shared/theme'
 
@@ -19,7 +19,7 @@ export const SubTitle = styled(Text)`
   margin-top: ${sizes(6)};
 `
 
-export const Wrapper = styled(StudioContainer)`
+export const Wrapper = styled(LimitedWidthContainer)`
   margin: ${sizes(16)} auto;
   max-width: 600px;
   text-align: center;

+ 79 - 0
src/views/viewer/ChannelView/ChannelAbout.style.tsx

@@ -0,0 +1,79 @@
+import styled from '@emotion/styled'
+
+import { Avatar, Text } from '@/shared/components'
+import { colors, media, sizes } from '@/shared/theme'
+
+export const TextContainer = styled.div`
+  display: grid;
+  grid-gap: ${sizes(4)};
+  padding-bottom: ${sizes(8)};
+  margin-bottom: ${sizes(8)};
+  border-bottom: 1px solid ${colors.gray[600]};
+`
+
+export const Container = styled.div`
+  display: grid;
+  gap: 142px;
+  grid-template-columns: minmax(0, 1fr) minmax(206px, 326px);
+  margin-bottom: 50px;
+  ${media.base} {
+    gap: 32px;
+    grid-template-columns: 1fr;
+    grid-template-rows: auto 1fr;
+  }
+  ${media.small} {
+    gap: 79px;
+    grid-template-rows: initial;
+    grid-template-columns: minmax(0, 1fr) minmax(206px, 228px);
+  }
+  ${media.medium} {
+    gap: 94px;
+    grid-template-columns: minmax(0, 1fr) minmax(206px, 260px);
+  }
+`
+
+export const LinksContainer = styled.div`
+  display: grid;
+  grid-gap: ${sizes(6)};
+`
+
+export const Links = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+
+  > a {
+    margin-right: ${sizes(12)};
+    margin-bottom: ${sizes(6)};
+  }
+`
+
+export const DetailsContainer = styled.div`
+  ${media.base} {
+    grid-row: 1;
+  }
+  ${media.small} {
+    grid-row: initial;
+  }
+`
+
+export const Details = styled.div`
+  display: grid;
+  gap: ${sizes(2)};
+  padding-bottom: ${sizes(4)};
+  border-bottom: 1px solid ${colors.gray[600]};
+  margin-bottom: ${sizes(4)};
+`
+
+export const DetailsText = styled(Text)`
+  margin-bottom: ${sizes(4)};
+`
+
+export const AvatarContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: ${sizes(2)};
+`
+
+export const StyledAvatar = styled(Avatar)`
+  margin-right: ${sizes(2)};
+`

+ 80 - 0
src/views/viewer/ChannelView/ChannelAbout.tsx

@@ -0,0 +1,80 @@
+import React from 'react'
+import { useParams } from 'react-router'
+
+import { useChannel, useChannelVideoCount } from '@/api/hooks'
+import { languages } from '@/config/languages'
+import { Text } from '@/shared/components'
+import { formatDate } from '@/utils/time'
+
+import {
+  AvatarContainer,
+  Container,
+  Details,
+  DetailsContainer,
+  DetailsText,
+  StyledAvatar,
+  TextContainer,
+} from './ChannelAbout.style'
+
+export const ChannelAbout = () => {
+  const { id } = useParams()
+  const { channel } = useChannel(id)
+  const { videoCount } = useChannelVideoCount(id)
+  return (
+    <Container>
+      <div>
+        {!!channel?.description && (
+          <TextContainer>
+            <Text variant="h4">Description</Text>
+            <Text variant="body1" secondary>
+              {channel.description}
+            </Text>
+          </TextContainer>
+        )}
+      </div>
+      <DetailsContainer>
+        <DetailsText variant="h4">Details</DetailsText>
+
+        <Details>
+          <Text variant="caption" secondary>
+            Owned by member
+          </Text>
+          <AvatarContainer>
+            <StyledAvatar assetUrl={undefined} />
+            <Text variant="body1">placeholder</Text>
+          </AvatarContainer>
+        </Details>
+
+        <Details>
+          <Text variant="caption" secondary>
+            Joined on
+          </Text>
+          <Text variant="body1">{channel?.createdAt ? formatDate(new Date(channel.createdAt)) : ''}</Text>
+        </Details>
+
+        <Details>
+          <Text variant="caption" secondary>
+            Num. of views
+          </Text>
+          <Text variant="body1">7 245 345</Text>
+        </Details>
+
+        <Details>
+          <Text variant="caption" secondary>
+            Num. of videos
+          </Text>
+          <Text variant="body1">{videoCount}</Text>
+        </Details>
+
+        <Details>
+          <Text variant="caption" secondary>
+            Language
+          </Text>
+          <Text variant="body1">
+            {channel?.language?.iso ? languages.find(({ value }) => value === channel.language?.iso)?.name : ''}
+          </Text>
+        </Details>
+      </DetailsContainer>
+    </Container>
+  )
+}

+ 53 - 45
src/views/viewer/ChannelView/ChannelView.style.tsx

@@ -2,54 +2,24 @@ import styled from '@emotion/styled'
 import { fluidRange } from 'polished'
 
 import { ChannelLink } from '@/components'
-import { CONTENT_OVERLAP_MAP, SkeletonLoader, Text } from '@/shared/components'
+import { Button, SkeletonLoader, Text } from '@/shared/components'
 import { colors, media, sizes, typography } from '@/shared/theme'
 
-export const Header = styled.section`
-  position: relative;
-  padding-bottom: 50px;
-
-  ${media.medium} {
-    padding-bottom: 0;
-  }
-`
-
 const SM_TITLE_HEIGHT = '44px'
 const TITLE_HEIGHT = '51px'
 const SM_SUBTITLE_HEIGHT = '24px'
 const SUBTITLE_HEIGHT = '27px'
 
-const INFO_BOTTOM_MARGIN = 75
-
 export const TitleSection = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: start;
+  display: grid;
+  grid-template-columns: auto 1fr;
+  grid-row-gap: ${sizes(4)};
+  align-items: center;
   width: 100%;
-  margin-top: -64px;
-
-  ${media.small} {
-    margin-top: -100px;
-    flex-direction: row;
-    align-items: center;
-  }
-
-  ${media.medium} {
-    position: absolute;
-    margin-top: 0;
-    bottom: ${CONTENT_OVERLAP_MAP.MEDIUM + INFO_BOTTOM_MARGIN}px;
-  }
+  margin: ${sizes(8)} 0 ${sizes(14)} 0;
 
-  ${media.large} {
-    bottom: ${CONTENT_OVERLAP_MAP.LARGE + INFO_BOTTOM_MARGIN}px;
-  }
-
-  ${media.xlarge} {
-    bottom: ${CONTENT_OVERLAP_MAP.XLARGE + INFO_BOTTOM_MARGIN}px;
-  }
-
-  ${media.xxlarge} {
-    bottom: ${CONTENT_OVERLAP_MAP.XXLARGE + INFO_BOTTOM_MARGIN}px;
+  ${media.compact} {
+    grid-template-columns: auto 1fr auto;
   }
 `
 export const TitleContainer = styled.div`
@@ -64,24 +34,33 @@ export const TitleContainer = styled.div`
 `
 
 export const Title = styled(Text)`
-  ${fluidRange({ prop: 'fontSize', fromSize: '32px', toSize: '40px' })};
+  ${fluidRange({ prop: 'fontSize', fromSize: '24px', toSize: '40px' })};
 
   line-height: 1;
-  padding: ${sizes(1)} ${sizes(2)} ${sizes(2)};
-  background-color: ${colors.gray[800]};
+  margin-bottom: 0;
+  padding: ${sizes(1)} ${sizes(2)} 5px;
   white-space: nowrap;
   text-overflow: ellipsis;
   overflow: hidden;
   max-width: 600px;
 `
 
+export const SortContainer = styled.div`
+  display: none;
+  grid-gap: 8px;
+  grid-template-columns: auto 1fr;
+  align-items: center;
+  ${media.medium} {
+    display: grid;
+  }
+`
+
 export const SubTitle = styled(Text)`
   ${fluidRange({ prop: 'fontSize', fromSize: '14px', toSize: '18px' })};
 
   padding: ${sizes(1)} ${sizes(2)};
-  margin-top: ${sizes(2)};
-  color: ${colors.white};
-  background-color: ${colors.gray[800]};
+  margin-top: ${sizes(1)};
+  color: ${colors.gray[300]};
   display: inline-block;
 `
 
@@ -123,10 +102,39 @@ export const StyledButtonContainer = styled.div`
   margin-top: ${sizes(2)};
   z-index: 2;
   background-color: ${colors.transparentBlack[54]};
+  grid-column: 1 / span 2;
+  width: 100%;
 
-  ${media.small} {
+  ${media.compact} {
+    grid-column: initial;
     margin-top: 0;
     margin-left: auto;
     align-self: center;
   }
 `
+
+export const StyledButton = styled(Button)`
+  width: 100%;
+`
+
+export const PaginationContainer = styled.div`
+  padding-top: ${sizes(6)};
+  padding-bottom: ${sizes(16)};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`
+
+export const TabsContainer = styled.div`
+  margin-bottom: ${sizes(8)};
+  border-bottom: solid 1px ${colors.gray[800]};
+
+  ${media.compact} {
+    padding-top: ${sizes(8)};
+  }
+
+  ${media.medium} {
+    display: grid;
+    grid-template-columns: 1fr 250px;
+  }
+`

+ 119 - 16
src/views/viewer/ChannelView/ChannelView.tsx

@@ -1,20 +1,27 @@
 import React, { useEffect, useState } from 'react'
 import { useParams } from 'react-router-dom'
 
-import { useChannel, useFollowChannel, useUnfollowChannel } from '@/api/hooks'
-import { InfiniteVideoGrid, ViewWrapper } from '@/components'
+import { useChannel, useFollowChannel, useUnfollowChannel, useVideosConnection } from '@/api/hooks'
+import { VideoOrderByInput } from '@/api/queries'
+import { LimitedWidthContainer, VideoTile, ViewWrapper } from '@/components'
+import { SORT_OPTIONS } from '@/config/sorting'
 import { AssetType, useAsset, useDialog, usePersonalDataStore } from '@/providers'
-import { Button, ChannelCover } from '@/shared/components'
+import { ChannelCover, Grid, Pagination, Select, Tabs, Text } from '@/shared/components'
+import { SvgGlyphCheck, SvgGlyphPlus } from '@/shared/icons'
 import { transitions } from '@/shared/theme'
 import { Logger } from '@/utils/logger'
 import { formatNumberShort } from '@/utils/number'
 
+import { ChannelAbout } from './ChannelAbout'
 import {
-  Header,
+  PaginationContainer,
+  SortContainer,
+  StyledButton,
   StyledButtonContainer,
   StyledChannelLink,
   SubTitle,
   SubTitleSkeletonLoader,
+  TabsContainer,
   Title,
   TitleContainer,
   TitleSection,
@@ -22,6 +29,10 @@ import {
   VideoSection,
 } from './ChannelView.style'
 
+const TABS = ['Videos', 'Information'] as const
+const INITIAL_FIRST = 50
+const INITIAL_VIDEOS_PER_ROW = 4
+const ROWS_AMOUNT = 4
 export const ChannelView: React.FC = () => {
   const [openUnfollowDialog, closeUnfollowDialog] = useDialog()
   const { id } = useParams()
@@ -31,11 +42,26 @@ export const ChannelView: React.FC = () => {
   const followedChannels = usePersonalDataStore((state) => state.followedChannels)
   const updateChannelFollowing = usePersonalDataStore((state) => state.actions.updateChannelFollowing)
   const [isFollowing, setFollowing] = useState<boolean>()
+  const [currentVideosTab, setCurrentVideosTab] = useState(0)
+  const currentTabName = TABS[currentVideosTab]
+  const [sortVideosBy, setSortVideosBy] = useState<VideoOrderByInput | undefined>(VideoOrderByInput.CreatedAtDesc)
+  const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
   const { url: coverPhotoUrl } = useAsset({
     entity: channel,
     assetType: AssetType.COVER,
   })
-
+  const { currentPage, setCurrentPage } = usePagination(0)
+  const { edges, totalCount, loading: loadingVideos, error: videosError, refetch } = useVideosConnection(
+    {
+      first: INITIAL_FIRST,
+      orderBy: sortVideosBy,
+      where: {
+        channelId_eq: id,
+        isPublic_eq: true,
+      },
+    },
+    { notifyOnNetworkStatusChange: true, fetchPolicy: 'cache-and-network' }
+  )
   useEffect(() => {
     const isFollowing = followedChannels.some((channel) => channel.id === id)
     setFollowing(isFollowing)
@@ -77,20 +103,76 @@ export const ChannelView: React.FC = () => {
       Logger.warn('Failed to update Channel following', { error })
     }
   }
-  if (error) {
+  if (videosError) {
+    throw videosError
+  } else if (error) {
     throw error
   }
 
+  const handleSorting = (value?: VideoOrderByInput | null | undefined) => {
+    if (value) {
+      setSortVideosBy(value)
+      refetch({ orderBy: value })
+    }
+  }
+  const handleOnResizeGrid = (sizes: number[]) => setVideosPerRow(sizes.length)
+  const handleChangePage = (page: number) => {
+    setCurrentPage(page)
+  }
+  const handleSetCurrentTab = async (tab: number) => {
+    setCurrentVideosTab(tab)
+  }
+  const videosPerPage = ROWS_AMOUNT * videosPerRow
+
+  const videos = edges
+    ?.map((edge) => edge.node)
+    .slice(currentPage * videosPerPage, currentPage * videosPerPage + videosPerPage)
+  const placeholderItems = Array.from(
+    { length: loadingVideos ? videosPerPage - (videos ? videos.length : 0) : 0 },
+    () => ({
+      id: undefined,
+      progress: undefined,
+    })
+  )
+  const videosWithPlaceholders = [...(videos || []), ...placeholderItems]
+  const mappedTabs = TABS.map((tab) => ({ name: tab, badgeNumber: 0 }))
+
+  const TabContent = () => {
+    switch (currentTabName) {
+      case 'Videos':
+        return (
+          <>
+            <VideoSection className={transitions.names.slide}>
+              <Grid maxColumns={null} onResize={handleOnResizeGrid}>
+                {videosWithPlaceholders.map((video, idx) => (
+                  <VideoTile key={idx} id={video.id} showChannel={false} />
+                ))}
+              </Grid>
+            </VideoSection>
+            <PaginationContainer>
+              <Pagination
+                onChangePage={handleChangePage}
+                page={currentPage}
+                itemsPerPage={videosPerPage}
+                totalCount={totalCount}
+              />
+            </PaginationContainer>
+          </>
+        )
+      case 'Information':
+        return <ChannelAbout />
+    }
+  }
+
   if (!loading && !channel) {
     return <span>Channel not found</span>
   }
-
   return (
     <ViewWrapper>
-      <Header>
-        <ChannelCover assetUrl={coverPhotoUrl} />
+      <ChannelCover assetUrl={coverPhotoUrl} />
+      <LimitedWidthContainer>
         <TitleSection className={transitions.names.slide}>
-          <StyledChannelLink id={channel?.id} avatarSize="view" hideHandle noLink />
+          <StyledChannelLink id={channel?.id} avatarSize="channel" hideHandle noLink />
           <TitleContainer>
             {channel ? (
               <>
@@ -105,15 +187,36 @@ export const ChannelView: React.FC = () => {
             )}
           </TitleContainer>
           <StyledButtonContainer>
-            <Button variant={isFollowing ? 'secondary' : 'primary'} onClick={handleFollow} size="large">
+            <StyledButton
+              icon={isFollowing ? <SvgGlyphCheck /> : <SvgGlyphPlus />}
+              variant={isFollowing ? 'secondary' : 'primary'}
+              onClick={handleFollow}
+              size="large"
+            >
               {isFollowing ? 'Unfollow' : 'Follow'}
-            </Button>
+            </StyledButton>
           </StyledButtonContainer>
         </TitleSection>
-      </Header>
-      <VideoSection className={transitions.names.slide}>
-        <InfiniteVideoGrid channelId={id} showChannel={false} />
-      </VideoSection>
+        <TabsContainer>
+          <Tabs initialIndex={0} tabs={mappedTabs} onSelectTab={handleSetCurrentTab} />
+          {currentTabName === 'Videos' && (
+            <SortContainer>
+              <Text variant="body2">Sort by</Text>
+              <Select helperText={null} value={sortVideosBy} items={SORT_OPTIONS} onChange={handleSorting} />
+            </SortContainer>
+          )}
+        </TabsContainer>
+        <TabContent />
+      </LimitedWidthContainer>
     </ViewWrapper>
   )
 }
+
+const usePagination = (currentTab: number) => {
+  const [currentPage, setCurrentPage] = useState(0)
+  // reset the pagination when changing tabs
+  useEffect(() => {
+    setCurrentPage(0)
+  }, [currentTab])
+  return { currentPage, setCurrentPage }
+}