Procházet zdrojové kódy

add loading states

Klaudiusz Dembler před 4 roky
rodič
revize
6b5cee7ba0

+ 2 - 3
src/components/ChannelGallery.tsx

@@ -8,7 +8,6 @@ import { ChannelFields } from '@/api/queries/__generated__/ChannelFields'
 
 type ChannelGalleryProps = {
   title?: string
-  action?: string
   channels?: ChannelFields[]
   loading?: boolean
 }
@@ -17,11 +16,11 @@ const PLACEHOLDERS_COUNT = 12
 
 const trackPadding = `${spacing.xs} 0 0 ${spacing.xs}`
 
-const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, action, channels, loading }) => {
+const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels, loading }) => {
   const displayPlaceholders = loading || !channels
 
   return (
-    <Gallery title={title} action={action} trackPadding={trackPadding}>
+    <Gallery title={title} trackPadding={trackPadding}>
       {displayPlaceholders
         ? Array.from({ length: PLACEHOLDERS_COUNT }).map((_, idx) => (
             <StyledChannelPreviewBase key={`channel-placeholder-${idx}`} />

+ 17 - 0
src/components/PlaceholderVideoGrid.tsx

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

+ 2 - 3
src/components/SeriesGallery.tsx

@@ -4,7 +4,6 @@ import { Gallery, SeriesPreview } from '@/shared/components'
 
 type SeriesGalleryProps = {
   title: string
-  action: string
 }
 
 const series = [
@@ -58,8 +57,8 @@ const series = [
   },
 ]
 
-const SeriesGallery: React.FC<Partial<SeriesGalleryProps>> = ({ title, action }) => (
-  <Gallery title={title} action={action}>
+const SeriesGallery: React.FC<Partial<SeriesGalleryProps>> = ({ title }) => (
+  <Gallery title={title}>
     {series.map((series) => (
       <SeriesPreview
         key={series.series}

+ 0 - 52
src/components/VideoBestMatch/VideoBestMatch.style.tsx

@@ -1,52 +0,0 @@
-import styled from '@emotion/styled'
-import { fluidRange } from 'polished'
-import { colors, breakpoints as bp } from '@/shared/theme'
-
-export const Container = styled.div`
-  color: ${colors.gray[300]};
-  padding-right: 2rem;
-`
-
-export const Content = styled.div`
-  display: grid;
-  grid-template-columns: 1fr;
-  grid-column-gap: 24px;
-
-  @media (min-width: ${bp.small}) {
-    grid-template-columns: min(650px, 50%) 1fr;
-  }
-`
-
-export const PosterContainer = styled.div`
-  position: relative;
-  width: 100%;
-  height: 0;
-  max-height: 350px;
-  padding-top: 56.25%;
-  :hover {
-    cursor: pointer;
-  }
-`
-export const Poster = styled.img`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  top: 0;
-  left: 0;
-`
-export const TitleContainer = styled.div`
-  max-width: 500px;
-`
-export const Title = styled.h1`
-  ${fluidRange({ prop: 'fontSize', fromSize: '32px', toSize: '40px' })};
-  line-height: 1.2;
-  margin: 0;
-  margin-bottom: 12px;
-`
-
-export const InnerContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-  padding: 1.875rem 0;
-`

+ 0 - 39
src/components/VideoBestMatch/VideoBestMatch.tsx

@@ -1,39 +0,0 @@
-import React from 'react'
-
-import { VideoFields } from '@/api/queries/__generated__/VideoFields'
-import { formatVideoViewsAndDate } from '@/utils/video'
-import {
-  Container,
-  Content,
-  InnerContainer,
-  Poster,
-  PosterContainer,
-  Title,
-  TitleContainer,
-} from './VideoBestMatch.style'
-
-type BestVideoMatchProps = {
-  video: VideoFields
-  onClick: (e: React.MouseEvent<HTMLImageElement>) => void
-}
-
-const BestVideoMatch: React.FC<BestVideoMatchProps> = ({
-  video: { thumbnailURL, title, views, publishedOnJoystreamAt },
-  onClick,
-}) => (
-  <Container>
-    <h3>Best Match</h3>
-    <Content>
-      <PosterContainer>
-        <Poster src={thumbnailURL} onClick={onClick} />
-      </PosterContainer>
-      <InnerContainer>
-        <TitleContainer>
-          <Title>{title}</Title>
-          <span>{formatVideoViewsAndDate(views, publishedOnJoystreamAt, { fullViews: true })}</span>
-        </TitleContainer>
-      </InnerContainer>
-    </Content>
-  </Container>
-)
-export default BestVideoMatch

+ 0 - 2
src/components/VideoBestMatch/index.ts

@@ -1,2 +0,0 @@
-import VideoBestMatch from './VideoBestMatch'
-export default VideoBestMatch

+ 15 - 13
src/components/VideoGallery.tsx

@@ -3,15 +3,15 @@ import { BreakPoint } from 'react-glider'
 
 import styled from '@emotion/styled'
 
-import { Gallery, MAX_VIDEO_PREVIEW_WIDTH, VideoPreviewBase, breakpointsOfGrid } from '@/shared/components'
+import { breakpointsOfGrid, Gallery, MAX_VIDEO_PREVIEW_WIDTH, VideoPreviewBase } from '@/shared/components'
 import VideoPreview from './VideoPreviewWithNavigation'
 import { VideoFields } from '@/api/queries/__generated__/VideoFields'
 
-import { spacing } from '@/shared/theme'
+import { sizes, spacing } from '@/shared/theme'
+import { css } from '@emotion/core'
 
 type VideoGalleryProps = {
   title?: string
-  action?: string
   videos?: VideoFields[]
   loading?: boolean
 }
@@ -33,11 +33,13 @@ const breakpoints = breakpointsOfGrid({
   },
 })) as BreakPoint[]
 
-const VideoGallery: React.FC<VideoGalleryProps> = ({ title, action, videos, loading }) => {
+console.log(breakpoints)
+
+const VideoGallery: React.FC<VideoGalleryProps> = ({ title, videos, loading }) => {
   const displayPlaceholders = loading || !videos
 
   return (
-    <Gallery title={title} action={action} trackPadding={trackPadding} responsive={breakpoints}>
+    <Gallery title={title} trackPadding={trackPadding} responsive={breakpoints}>
       {displayPlaceholders
         ? Array.from({ length: PLACEHOLDERS_COUNT }).map((_, idx) => (
             <StyledVideoPreviewBase key={`video-placeholder-${idx}`} />
@@ -60,19 +62,19 @@ const VideoGallery: React.FC<VideoGalleryProps> = ({ title, action, videos, load
   )
 }
 
-const StyledVideoPreviewBase = styled(VideoPreviewBase)`
+const videoPreviewCss = css`
   & + & {
-    margin-left: 1.25rem;
+    margin-left: ${sizes.b6}px;
   }
 
-  width: ${MAX_VIDEO_PREVIEW_WIDTH};
+  min-width: ${MAX_VIDEO_PREVIEW_WIDTH};
 `
-const StyledVideoPreview = styled(VideoPreview)`
-  & + & {
-    margin-left: 24px;
-  }
 
-  width: ${MAX_VIDEO_PREVIEW_WIDTH};
+const StyledVideoPreviewBase = styled(VideoPreviewBase)`
+  ${videoPreviewCss};
+`
+const StyledVideoPreview = styled(VideoPreview)`
+  ${videoPreviewCss};
 `
 
 export default VideoGallery

+ 1 - 1
src/components/index.ts

@@ -4,8 +4,8 @@ export { default as Hero } from './Hero'
 export { default as SeriesGallery } from './SeriesGallery'
 export { default as ChannelGallery } from './ChannelGallery'
 export { default as Navbar } from './Navbar'
-export { default as VideoBestMatch } from './VideoBestMatch'
 export { default as VideoGrid } from './VideoGrid'
+export { default as PlaceholderVideoGrid } from './PlaceholderVideoGrid'
 export { default as VideoPreview } from './VideoPreviewWithNavigation'
 export { default as ChannelPreview } from './ChannelPreviewWithNavigation'
 export { default as ChannelGrid } from './ChannelGrid'

+ 1 - 0
src/shared/components/Carousel/Carousel.style.ts

@@ -38,6 +38,7 @@ export const Container = styled.div<{ trackPadding: string }>`
 
   .glider-track {
     padding: ${(props) => props.trackPadding};
+    align-items: start;
   }
 `
 

+ 6 - 7
src/shared/components/Carousel/Carousel.tsx

@@ -1,7 +1,7 @@
-import React, { useState, useLayoutEffect, useRef } from 'react'
-import { GliderProps, GliderMethods } from 'react-glider'
+import React, { useLayoutEffect, useRef, useState } from 'react'
+import { GliderMethods, GliderProps } from 'react-glider'
 
-import { Container, StyledGlider, Arrow } from './Carousel.style'
+import { Arrow, Container, StyledGlider } from './Carousel.style'
 
 import 'glider-js/glider.min.css'
 
@@ -11,10 +11,9 @@ type CarouselProps = {
 
 type TrackProps = {
   className?: string
-  padding?: string
 }
-const Track: React.FC<TrackProps> = ({ className = '', ...props }) => (
-  <div className={`glider-track ${className}`} {...props} />
+const Track: React.FC<TrackProps> = ({ className = '', children }) => (
+  <div className={`glider-track ${className}`}>{children}</div>
 )
 
 const RightArrow = <Arrow name="chevron-right" />
@@ -70,7 +69,7 @@ const Carousel: React.FC<CarouselProps> = ({
         arrows={(arrows as unknown) as { prev: string; next: string }}
         {...gliderProps}
       >
-        <Track padding={trackPadding}>{children}</Track>
+        <Track>{children}</Track>
       </StyledGlider>
     </Container>
   )

+ 6 - 12
src/shared/components/Gallery/Gallery.tsx

@@ -1,26 +1,20 @@
 import React from 'react'
 import { Container, HeadingContainer } from './Gallery.style'
-import Button from '../Button'
 import Carousel from '../Carousel'
 
 type GalleryProps = {
   title?: string
-  action?: string
-  onClick?: () => void
   className?: string
 } & React.ComponentProps<typeof Carousel>
 
-const Gallery: React.FC<GalleryProps> = ({ title, action = '', className, onClick, ...carouselProps }) => {
+const Gallery: React.FC<GalleryProps> = ({ title, className, ...carouselProps }) => {
   return (
     <Container className={className}>
-      <HeadingContainer>
-        {title && <h4>{title}</h4>}
-        {action && (
-          <Button variant="tertiary" onClick={onClick}>
-            {action}
-          </Button>
-        )}
-      </HeadingContainer>
+      {title && (
+        <HeadingContainer>
+          <h4>{title}</h4>
+        </HeadingContainer>
+      )}
       <Carousel {...carouselProps} />
     </Container>
   )

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

@@ -6,6 +6,7 @@ import { darken } from 'polished'
 type PlaceholderProps = {
   width?: string | number
   height?: string | number
+  bottomSpace?: string | number
   rounded?: boolean
 }
 
@@ -19,9 +20,11 @@ const pulse = keyframes`
     background-color: ${darken(0.15, colors.gray[400])}
   }
 `
+
 const Placeholder = styled.div<PlaceholderProps>`
   width: ${({ width = '100%' }) => getPropValue(width)};
   height: ${({ height = '100%' }) => getPropValue(height)};
+  margin-bottom: ${({ bottomSpace = 0 }) => getPropValue(bottomSpace)};
   border-radius: ${({ rounded = false }) => (rounded ? '100%' : '0')};
   background-color: ${colors.gray['400']};
   animation: ${pulse} 0.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;

+ 10 - 4
src/shared/components/VideoPreview/VideoPreview.styles.tsx

@@ -1,5 +1,6 @@
 import React from 'react'
 import styled from '@emotion/styled'
+import { fluidRange } from 'polished'
 import { colors, spacing, typography } from '../../theme'
 import Avatar from '../Avatar'
 import Icon from '../Icon'
@@ -7,6 +8,10 @@ import { HOVER_BORDER_SIZE } from './VideoPreviewBase.styles'
 
 type CoverImageProps = Record<string, unknown>
 
+type MainProps = {
+  main: boolean
+}
+
 type ChannelProps = {
   channelClickable: boolean
 }
@@ -82,11 +87,12 @@ export const StyledAvatar = styled(Avatar)<ChannelProps>`
   cursor: ${({ channelClickable }) => (channelClickable ? 'pointer' : 'auto')};
 `
 
-export const TitleHeader = styled.h3`
+export const TitleHeader = styled.h3<MainProps>`
   margin: 0;
   font-weight: ${typography.weights.bold};
   font-size: ${typography.sizes.h6};
-  line-height: 1.25rem;
+  ${({ main }) => main && fluidRange({ prop: 'fontSize', fromSize: '24px', toSize: '40px' })};
+  line-height: ${({ main }) => (main ? 1 : 1.25)};
   color: ${colors.white};
   display: inline-block;
 `
@@ -98,6 +104,6 @@ export const ChannelName = styled.span<ChannelProps>`
   cursor: ${({ channelClickable }) => (channelClickable ? 'pointer' : 'auto')};
 `
 
-export const MetaText = styled.span`
-  font-size: ${typography.sizes.subtitle2};
+export const MetaText = styled.span<MainProps>`
+  font-size: ${({ main }) => (main ? typography.sizes.h6 : typography.sizes.subtitle2)};
 `

+ 6 - 2
src/shared/components/VideoPreview/VideoPreview.tsx

@@ -28,6 +28,8 @@ type VideoPreviewProps = {
 
   showChannel?: boolean
   showMeta?: boolean
+  main?: boolean
+
   imgRef?: React.Ref<HTMLImageElement>
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
   onChannelClick?: (e: React.MouseEvent<HTMLElement>) => void
@@ -45,6 +47,7 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
   posterURL,
   showChannel = true,
   showMeta = true,
+  main = false,
   imgRef,
   onClick,
   onChannelClick,
@@ -83,7 +86,7 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
     </>
   )
 
-  const titleNode = <TitleHeader>{title}</TitleHeader>
+  const titleNode = <TitleHeader main={main}>{title}</TitleHeader>
 
   const channelAvatarNode = (
     <StyledAvatar
@@ -101,7 +104,7 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
     </ChannelName>
   )
 
-  const metaNode = <MetaText>{formatVideoViewsAndDate(views, createdAt)}</MetaText>
+  const metaNode = <MetaText main={main}>{formatVideoViewsAndDate(views, createdAt, { fullViews: main })}</MetaText>
 
   return (
     <VideoPreviewBase
@@ -111,6 +114,7 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
       channelAvatarNode={channelAvatarNode}
       channelNameNode={channelNameNode}
       showMeta={showMeta}
+      main={main}
       metaNode={metaNode}
       onClick={onClick && handleClick}
       className={className}

+ 39 - 9
src/shared/components/VideoPreview/VideoPreviewBase.styles.tsx

@@ -1,13 +1,17 @@
 import styled from '@emotion/styled'
-import { keyframes } from '@emotion/core'
-import { colors, spacing } from '@/shared/theme'
+import { css, keyframes } from '@emotion/core'
+import { breakpoints, colors, spacing } from '@/shared/theme'
 import { CoverHoverOverlay, CoverIcon, ProgressOverlay } from './VideoPreview.styles'
 
 export const HOVER_BORDER_SIZE = '2px'
 
+type MainProps = {
+  main: boolean
+}
+
 type ContainerProps = {
   clickable: boolean
-}
+} & MainProps
 
 export const MAX_VIDEO_PREVIEW_WIDTH = '320px'
 
@@ -19,21 +23,39 @@ const fadeIn = keyframes`
     opacity: 1
   }
 `
+
+export const CoverWrapper = styled.div`
+  max-width: 650px;
+  width: 100%;
+`
+
 export const CoverContainer = styled.div`
   position: relative;
   width: 100%;
   height: 0;
   padding-top: 56.25%;
+
   transition-property: box-shadow, transform;
   transition-duration: 0.4s;
   transition-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1);
   animation: ${fadeIn} 0.5s ease-in;
 `
 
+const mainContainerCss = css`
+  @media screen and (min-width: ${breakpoints.medium}) {
+    flex-direction: row;
+  }
+`
+
 export const Container = styled.article<ContainerProps>`
+  width: 100%;
   color: ${colors.gray[300]};
   cursor: ${({ clickable }) => (clickable ? 'pointer' : 'auto')};
-  display: inline-block;
+
+  display: inline-flex;
+  flex-direction: column;
+  ${({ main }) => main && mainContainerCss}
+
   ${({ clickable }) =>
     clickable &&
     `
@@ -55,12 +77,20 @@ export const Container = styled.article<ContainerProps>`
 						bottom: ${HOVER_BORDER_SIZE};
 					}
 				}
-			`}
+			`};
 `
 
-export const InfoContainer = styled.div`
+const mainInfoContainerCss = css`
+  @media screen and (min-width: ${breakpoints.medium}) {
+    margin: ${spacing.xxl} 0 0 ${spacing.xl};
+  }
+`
+
+export const InfoContainer = styled.div<MainProps>`
+  width: 100%;
   display: flex;
-  margin-top: ${spacing.s};
+  margin-top: ${({ main }) => (main ? spacing.m : spacing.s)};
+  ${({ main }) => main && mainInfoContainerCss};
 `
 
 export const AvatarContainer = styled.div`
@@ -77,7 +107,7 @@ export const TextContainer = styled.div`
   width: 100%;
 `
 
-export const MetaContainer = styled.div`
-  margin-top: ${spacing.xs};
+export const MetaContainer = styled.div<MainProps>`
+  margin-top: ${({ main }) => (main ? spacing.s : spacing.xs)};
   width: 100%;
 `

+ 15 - 8
src/shared/components/VideoPreview/VideoPreviewBase.tsx

@@ -6,6 +6,7 @@ import {
   InfoContainer,
   MetaContainer,
   TextContainer,
+  CoverWrapper,
 } from './VideoPreviewBase.styles'
 import styled from '@emotion/styled'
 import Placeholder from '../Placeholder'
@@ -17,6 +18,7 @@ type VideoPreviewBaseProps = {
   channelAvatarNode?: React.ReactNode
   channelNameNode?: React.ReactNode
   showMeta?: boolean
+  main?: boolean
   metaNode?: React.ReactNode
   onClick?: (e: React.MouseEvent<HTMLElement>) => void
   className?: string
@@ -29,27 +31,32 @@ const VideoPreviewBase: React.FC<VideoPreviewBaseProps> = ({
   channelAvatarNode,
   channelNameNode,
   showMeta = true,
+  main = false,
   metaNode,
   onClick,
   className,
 }) => {
   const clickable = !!onClick
 
+  const displayChannel = showChannel && !main
+
   const coverPlaceholder = <CoverPlaceholder />
   const channelAvatarPlaceholder = <Placeholder rounded />
-  const titlePlaceholder = <Placeholder height="18px" width="60%" />
+  const titlePlaceholder = <Placeholder height={main ? 45 : 18} width="60%" />
   const channelNamePlaceholder = <SpacedPlaceholder height="12px" width="60%" />
-  const metaPlaceholder = <SpacedPlaceholder height="12px" width="80%" />
+  const metaPlaceholder = <SpacedPlaceholder height={main ? 16 : 12} width={main ? '40%' : '80%'} />
 
   return (
-    <Container onClick={onClick} clickable={clickable} className={className}>
-      <CoverContainer>{coverNode || coverPlaceholder}</CoverContainer>
-      <InfoContainer>
-        {showChannel && <AvatarContainer>{channelAvatarNode || channelAvatarPlaceholder}</AvatarContainer>}
+    <Container onClick={onClick} clickable={clickable} main={main} className={className}>
+      <CoverWrapper>
+        <CoverContainer>{coverNode || coverPlaceholder}</CoverContainer>
+      </CoverWrapper>
+      <InfoContainer main={main}>
+        {displayChannel && <AvatarContainer>{channelAvatarNode || channelAvatarPlaceholder}</AvatarContainer>}
         <TextContainer>
           {titleNode || titlePlaceholder}
-          {showChannel && (channelNameNode || channelNamePlaceholder)}
-          {showMeta && <MetaContainer>{metaNode || metaPlaceholder}</MetaContainer>}
+          {displayChannel && (channelNameNode || channelNamePlaceholder)}
+          {showMeta && <MetaContainer main={main}>{metaNode || metaPlaceholder}</MetaContainer>}
         </TextContainer>
       </InfoContainer>
     </Container>

+ 26 - 5
src/views/ChannelView/ChannelView.style.tsx

@@ -1,10 +1,14 @@
 import styled from '@emotion/styled'
 import { fluidRange } from 'polished'
-import { Avatar } from '@/shared/components'
+import { Avatar, Placeholder } from '@/shared/components'
 import theme from '@/shared/theme'
+import { css } from '@emotion/core'
+
+const SM_TITLE_HEIGHT = '48px'
+const TITLE_HEIGHT = '56px'
 
 type ChannelHeaderProps = {
-  coverPhotoURL: string | null
+  coverPhotoURL: string
 }
 export const Header = styled.section<ChannelHeaderProps>`
   background-image: linear-gradient(0deg, #000000 10.85%, rgba(0, 0, 0, 0) 88.35%),
@@ -39,9 +43,9 @@ export const Title = styled.h1`
   ${fluidRange({ prop: 'fontSize', fromSize: '32px', toSize: '40px' })};
   font-weight: bold;
   margin: -4px 0 0;
-  line-height: 48px;
+  line-height: ${SM_TITLE_HEIGHT};
   @media screen and (min-width: ${theme.breakpoints.medium}) {
-    line-height: 56px;
+    line-height: ${TITLE_HEIGHT};
   }
 
   white-space: nowrap;
@@ -54,7 +58,7 @@ export const VideoSection = styled.section`
   margin-top: -100px;
 `
 
-export const StyledAvatar = styled(Avatar)`
+const avatarCss = css`
   width: 128px;
   height: 128px;
   margin-bottom: ${theme.sizes.b3}px;
@@ -65,3 +69,20 @@ export const StyledAvatar = styled(Avatar)`
     margin: 0 ${theme.sizes.b6}px 0 0;
   }
 `
+
+export const StyledAvatar = styled(Avatar)`
+  ${avatarCss};
+`
+
+export const AvatarPlaceholder = styled(Placeholder)`
+  ${avatarCss};
+  border-radius: 100%;
+`
+
+export const TitlePlaceholder = styled(Placeholder)`
+  width: 300px;
+  height: ${SM_TITLE_HEIGHT};
+  @media screen and (min-width: ${theme.breakpoints.medium}) {
+    height: ${TITLE_HEIGHT};
+  }
+`

+ 35 - 12
src/views/ChannelView/ChannelView.tsx

@@ -4,9 +4,20 @@ import { useQuery } from '@apollo/client'
 
 import { GET_CHANNEL } from '@/api/queries/channels'
 import { GetChannel, GetChannelVariables } from '@/api/queries/__generated__/GetChannel'
-import { VideoGrid } from '@/components'
+import { VideoGrid, PlaceholderVideoGrid } from '@/components'
 
-import { Header, StyledAvatar, Title, TitleContainer, TitleSection, VideoSection } from './ChannelView.style'
+import {
+  AvatarPlaceholder,
+  Header,
+  StyledAvatar,
+  Title,
+  TitleContainer,
+  TitlePlaceholder,
+  TitleSection,
+  VideoSection,
+} from './ChannelView.style'
+
+const DEFAULT_CHANNEL_COVER_URL = 'https://eu-central-1.linodeobjects.com/atlas-assets/default-channel-cover.png'
 
 const ChannelView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
@@ -14,24 +25,36 @@ const ChannelView: React.FC<RouteComponentProps> = () => {
     variables: { id },
   })
 
-  if (loading || !data?.channel) {
-    return <p>Loading Channel...</p>
-  }
   const videos = data?.channel?.videos || []
 
   return (
     <div>
-      <Header coverPhotoURL={data.channel.coverPhotoURL}>
+      <Header coverPhotoURL={data?.channel?.coverPhotoURL || DEFAULT_CHANNEL_COVER_URL}>
         <TitleSection>
-          <StyledAvatar img={data.channel.avatarPhotoURL} name={data.channel.handle} />
-          <TitleContainer>
-            <Title>{data.channel.handle}</Title>
-          </TitleContainer>
+          {data?.channel ? (
+            <>
+              <StyledAvatar img={data.channel.avatarPhotoURL} name={data.channel.handle} />
+              <TitleContainer>
+                <Title>{data.channel.handle}</Title>
+              </TitleContainer>
+            </>
+          ) : (
+            <>
+              <AvatarPlaceholder />
+              <TitlePlaceholder />
+            </>
+          )}
         </TitleSection>
       </Header>
-      {videos.length > 0 && (
+      {!loading ? (
+        videos.length > 0 && (
+          <VideoSection>
+            <VideoGrid videos={videos} showChannel={false} />
+          </VideoSection>
+        )
+      ) : (
         <VideoSection>
-          <VideoGrid videos={videos} showChannel={false} />
+          <PlaceholderVideoGrid />
         </VideoSection>
       )}
     </div>

+ 62 - 0
src/views/SearchView/AllResultsTab.tsx

@@ -0,0 +1,62 @@
+import React from 'react'
+import { Search_search_item_Channel, Search_search_item_Video } from '@/api/queries/__generated__/Search'
+import { VideoPreview, Placeholder, VideoPreviewBase } from '@/shared/components'
+import styled from '@emotion/styled'
+import { spacing, typography } from '@/shared/theme'
+import { ChannelGallery, VideoGallery } from '@/components'
+
+type AllResultsTabProps = {
+  videos: Search_search_item_Video[]
+  channels: Search_search_item_Channel[]
+  loading: boolean
+}
+
+const AllResultsTab: React.FC<AllResultsTabProps> = ({ videos: allVideos, channels, loading }) => {
+  const [bestMatch, ...videos] = allVideos
+
+  return (
+    <>
+      <div>
+        {loading && (
+          <>
+            <Placeholder width={200} height={16} bottomSpace={18} />
+            <VideoPreviewBase main />
+          </>
+        )}
+        {bestMatch && (
+          <>
+            <h3>Best Match</h3>
+            <VideoPreview
+              title={bestMatch.title}
+              duration={bestMatch.duration}
+              channelName={bestMatch.channel.handle}
+              createdAt={bestMatch.publishedOnJoystreamAt}
+              views={bestMatch.views}
+              posterURL={bestMatch.thumbnailURL}
+              main
+            />
+          </>
+        )}
+      </div>
+      {(videos.length > 0 || loading) && (
+        <div>
+          {loading ? <Placeholder width={200} height={16} bottomSpace={18} /> : <SectionHeader>Videos</SectionHeader>}
+          <VideoGallery videos={videos} loading={loading} />
+        </div>
+      )}
+      {(channels.length > 0 || loading) && (
+        <div>
+          {loading ? <Placeholder width={200} height={16} bottomSpace={18} /> : <SectionHeader>Channels</SectionHeader>}
+          <ChannelGallery channels={channels} loading={loading} />
+        </div>
+      )}
+    </>
+  )
+}
+
+const SectionHeader = styled.h5`
+  margin: 0 0 ${spacing.m};
+  font-size: ${typography.sizes.h5};
+`
+
+export default AllResultsTab

+ 11 - 34
src/views/SearchView.tsx → src/views/SearchView/SearchView.tsx

@@ -1,25 +1,20 @@
 import React, { useState, useMemo } from 'react'
 import styled from '@emotion/styled'
-import { spacing, typography, sizes } from '@/shared/theme'
-import { RouteComponentProps, navigate } from '@reach/router'
+import { sizes } from '@/shared/theme'
+import { RouteComponentProps } from '@reach/router'
 import { useQuery } from '@apollo/client'
 
 import { SEARCH } from '@/api/queries'
 import { Search, SearchVariables } from '@/api/queries/__generated__/Search'
 import { TabsMenu } from '@/shared/components'
-import { VideoGrid, ChannelGallery, VideoBestMatch, VideoGallery } from '@/components'
-import routes from '@/config/routes'
-import ChannelGrid from '@/components/ChannelGrid'
+import { VideoGrid, PlaceholderVideoGrid, ChannelGrid } from '@/components'
+import AllResultsTab from '@/views/SearchView/AllResultsTab'
 
 type SearchViewProps = {
   search?: string
 } & RouteComponentProps
 const tabs = ['all results', 'videos', 'channels']
 
-const SectionHeader = styled.h5`
-  margin: 0 0 ${spacing.m};
-  font-size: ${typography.sizes.h5};
-`
 const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
   const [selectedIndex, setSelectedIndex] = useState(0)
   const { data, loading } = useQuery<Search, SearchVariables>(SEARCH, { variables: { query_string: search } })
@@ -34,38 +29,20 @@ const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
     return { channels, videos }
   }
 
-  const { channels, videos: allVideos } = useMemo(() => getChannelsAndVideos(loading, data), [loading, data])
+  const { channels, videos } = useMemo(() => getChannelsAndVideos(loading, data), [loading, data])
 
-  if (loading || !data) {
-    return <p>Loading...</p>
-  }
-  if (!data.search) {
+  if (!loading && !data?.search) {
     return <p>Something went wrong...</p>
   }
 
-  const [bestMatch, ...videos] = allVideos
+  console.log({ videos, loading })
+
   return (
     <Container>
       <TabsMenu tabs={tabs} onSelectTab={setSelectedIndex} initialIndex={0} />
-      {bestMatch && selectedIndex === 0 && (
-        <VideoBestMatch video={bestMatch} onClick={() => navigate(routes.video(bestMatch.id))} />
-      )}
-      {videos.length > 0 && selectedIndex !== 2 && (
-        <div>
-          <SectionHeader>Videos</SectionHeader>
-          {selectedIndex === 0 ? <VideoGallery videos={videos} /> : <VideoGrid videos={videos} />}
-        </div>
-      )}
-      {channels.length > 0 && selectedIndex !== 1 && (
-        <div>
-          <SectionHeader>Channels</SectionHeader>
-          {selectedIndex === 0 ? (
-            <ChannelGallery channels={channels} />
-          ) : (
-            <ChannelGrid channels={channels} repeat="fill" />
-          )}
-        </div>
-      )}
+      {selectedIndex === 0 && <AllResultsTab loading={loading} videos={videos} channels={channels} />}
+      {selectedIndex === 1 && (loading ? <PlaceholderVideoGrid /> : <VideoGrid videos={videos} />)}
+      {selectedIndex === 2 && (loading ? <PlaceholderVideoGrid /> : <ChannelGrid channels={channels} repeat="fill" />)}
     </Container>
   )
 }

+ 13 - 0
src/views/SearchView/VideosResultTab.tsx

@@ -0,0 +1,13 @@
+import React from 'react'
+import { Search_search_item_Video } from '@/api/queries/__generated__/Search'
+
+type VideosResultTabProps = {
+  videos: Search_search_item_Video[]
+  loading: boolean
+}
+
+const VideosResultTab: React.FC<VideosResultTabProps> = () => {
+  return <div>VideosResultTab</div>
+}
+
+export default VideosResultTab

+ 2 - 0
src/views/SearchView/index.ts

@@ -0,0 +1,2 @@
+import SearchView from './SearchView'
+export default SearchView

+ 19 - 11
src/views/VideoView/VideoView.style.tsx

@@ -1,5 +1,5 @@
 import styled from '@emotion/styled'
-import { ChannelAvatar, VideoPlayer } from '@/shared/components'
+import { ChannelAvatar, Placeholder } from '@/shared/components'
 import theme from '@/shared/theme'
 
 export const Container = styled.div`
@@ -8,17 +8,26 @@ export const Container = styled.div`
 `
 
 export const PlayerContainer = styled.div`
+  width: min(1250px, 100%);
+`
+
+export const PlayerWrapper = styled.div`
   display: flex;
   justify-content: center;
 `
 
-export const InfoContainer = styled.div`
-  padding: ${theme.spacing.xxl} 0;
+export const PlayerPlaceholder = styled(Placeholder)`
+  padding-top: 56.25%;
+  height: 0;
 `
 
-export const TitleActionsContainer = styled.div`
-  display: flex;
-  justify-content: space-between;
+export const DescriptionPlaceholder = styled(Placeholder)`
+  height: 28px;
+  margin: ${theme.spacing.m} 0 0;
+`
+
+export const InfoContainer = styled.div`
+  padding: ${theme.spacing.xxl} 0;
 `
 
 export const Title = styled.h2`
@@ -32,8 +41,11 @@ export const Meta = styled.span`
   color: ${theme.colors.gray[300]};
 `
 
-export const StyledChannelAvatar = styled(ChannelAvatar)`
+export const ChannelContainer = styled.div`
   margin-top: ${theme.spacing.m};
+`
+
+export const StyledChannelAvatar = styled(ChannelAvatar)`
   :hover {
     cursor: pointer;
   }
@@ -58,7 +70,3 @@ export const MoreVideosHeader = styled.h5`
   margin: 0 0 ${theme.spacing.m};
   font-size: ${theme.typography.sizes.h5};
 `
-
-export const StyledVideoPlayer = styled(VideoPlayer)`
-  width: min(1250px, 100%);
-`

+ 53 - 30
src/views/VideoView/VideoView.tsx

@@ -1,20 +1,22 @@
 import React, { useEffect } from 'react'
 import { navigate, RouteComponentProps, useParams } from '@reach/router'
 import {
+  ChannelContainer,
   Container,
   DescriptionContainer,
+  DescriptionPlaceholder,
   InfoContainer,
   Meta,
   MoreVideosContainer,
   MoreVideosHeader,
   PlayerContainer,
+  PlayerPlaceholder,
+  PlayerWrapper,
   StyledChannelAvatar,
-  StyledVideoPlayer,
   Title,
-  TitleActionsContainer,
 } from './VideoView.style'
-import { VideoGrid } from '@/components'
-import { VideoPlayer } from '@/shared/components'
+import { VideoGrid, PlaceholderVideoGrid } from '@/components'
+import { Placeholder, VideoPlayer } from '@/shared/components'
 import { useMutation, useQuery } from '@apollo/client'
 import { ADD_VIDEO_VIEW, GET_VIDEO_WITH_CHANNEL_VIDEOS } from '@/api/queries'
 import { GetVideo, GetVideoVariables } from '@/api/queries/__generated__/GetVideo'
@@ -53,41 +55,62 @@ const VideoView: React.FC<RouteComponentProps> = () => {
     })
   }, [addVideoView, videoID])
 
-  if (loading || !data) {
-    return <p>Loading</p>
-  }
-
-  if (!data.video) {
+  if (!loading && !data?.video) {
     return <p>Video not found</p>
   }
 
-  const { title, views, publishedOnJoystreamAt, channel, description } = data.video
-
-  const descriptionLines = description.split('\n')
-
   return (
     <Container>
-      <PlayerContainer>
-        <StyledVideoPlayer src={data.video.media.location} autoplay fluid />
-      </PlayerContainer>
+      <PlayerWrapper>
+        <PlayerContainer>
+          {data?.video ? <VideoPlayer src={data.video.media.location} autoplay fluid /> : <PlayerPlaceholder />}
+        </PlayerContainer>
+      </PlayerWrapper>
       <InfoContainer>
-        <TitleActionsContainer>
-          <Title>{title}</Title>
-        </TitleActionsContainer>
-        <Meta>{formatVideoViewsAndDate(views, publishedOnJoystreamAt, { fullViews: true })}</Meta>
-        <StyledChannelAvatar
-          name={channel.handle}
-          avatarUrl={channel.avatarPhotoURL}
-          onClick={() => navigate(routes.channel(channel.id))}
-        />
+        {data?.video ? <Title>{data.video.title}</Title> : <Placeholder height={46} width={400} />}
+        <Meta>
+          {data?.video ? (
+            formatVideoViewsAndDate(data.video.views, data.video.publishedOnJoystreamAt, { fullViews: true })
+          ) : (
+            <Placeholder height={18} width={200} />
+          )}
+        </Meta>
+        <ChannelContainer>
+          {data?.video ? (
+            <StyledChannelAvatar
+              name={data.video.channel.handle}
+              avatarUrl={data.video.channel.avatarPhotoURL}
+              onClick={() => navigate(routes.channel(data.video!.channel.id))}
+            />
+          ) : (
+            <Placeholder height={32} width={200} />
+          )}
+        </ChannelContainer>
         <DescriptionContainer>
-          {descriptionLines.map((line, idx) => (
-            <p key={idx}>{line}</p>
-          ))}
+          {data?.video ? (
+            <>
+              {data.video.description.split('\n').map((line, idx) => (
+                <p key={idx}>{line}</p>
+              ))}
+            </>
+          ) : (
+            <>
+              <DescriptionPlaceholder width={700} />
+              <DescriptionPlaceholder width={400} />
+              <DescriptionPlaceholder width={800} />
+              <DescriptionPlaceholder width={300} />
+            </>
+          )}
         </DescriptionContainer>
         <MoreVideosContainer>
-          <MoreVideosHeader>More from {channel.handle}</MoreVideosHeader>
-          <VideoGrid videos={channel.videos} showChannel={false} />
+          <MoreVideosHeader>
+            {data?.video ? `More from ${data.video.channel.handle}` : <Placeholder height={23} width={300} />}
+          </MoreVideosHeader>
+          {data?.video ? (
+            <VideoGrid videos={data.video.channel.videos} showChannel={false} />
+          ) : (
+            <PlaceholderVideoGrid />
+          )}
         </MoreVideosContainer>
       </InfoContainer>
     </Container>