Browse Source

add placeholders for videos and channels loading

Klaudiusz Dembler 4 years ago
parent
commit
4172c51d30
26 changed files with 478 additions and 328 deletions
  1. 26 17
      packages/app/src/components/ChannelGallery.tsx
  2. 1 1
      packages/app/src/components/Hero.tsx
  3. 43 29
      packages/app/src/components/VideoGallery.tsx
  4. 4 4
      packages/app/src/mocking/data/mockImages.ts
  5. 1 1
      packages/app/src/mocking/server.ts
  6. 9 6
      packages/app/src/shared/components/Carousel/Carousel.style.ts
  7. 44 70
      packages/app/src/shared/components/Carousel/Carousel.tsx
  8. 5 2
      packages/app/src/shared/components/Carousel/index.ts
  9. 0 52
      packages/app/src/shared/components/ChannelPreview/ChannelPreview.style.tsx
  10. 22 16
      packages/app/src/shared/components/ChannelPreview/ChannelPreview.tsx
  11. 35 0
      packages/app/src/shared/components/ChannelPreview/ChannelPreviewBase.style.tsx
  12. 35 0
      packages/app/src/shared/components/ChannelPreview/ChannelPreviewBase.tsx
  13. 3 1
      packages/app/src/shared/components/ChannelPreview/index.ts
  14. 8 20
      packages/app/src/shared/components/Gallery/Gallery.tsx
  15. 17 0
      packages/app/src/shared/components/Placeholder/Placeholder.tsx
  16. 1 0
      packages/app/src/shared/components/Placeholder/index.ts
  17. 3 61
      packages/app/src/shared/components/VideoPreview/VideoPreview.styles.tsx
  18. 51 43
      packages/app/src/shared/components/VideoPreview/VideoPreview.tsx
  19. 72 0
      packages/app/src/shared/components/VideoPreview/VideoPreviewBase.styles.tsx
  20. 63 0
      packages/app/src/shared/components/VideoPreview/VideoPreviewBase.tsx
  21. 3 1
      packages/app/src/shared/components/VideoPreview/index.tsx
  22. 3 2
      packages/app/src/shared/components/index.ts
  23. 0 0
      packages/app/src/shared/stories/13-VideoPreview.stories.tsx
  24. 5 1
      packages/app/src/shared/stories/16-VideoPreview.stories.tsx
  25. 23 0
      packages/app/src/shared/stories/17-ChannelPreview.stories.tsx
  26. 1 1
      packages/app/src/shared/theme/typography.ts

+ 26 - 17
packages/app/src/components/ChannelGallery.tsx

@@ -1,6 +1,6 @@
 import React from 'react'
-import { css } from '@emotion/core'
-import { ChannelPreview, Gallery } from '@/shared/components'
+import styled from '@emotion/styled'
+import { ChannelPreview, ChannelPreviewBase, Gallery } from '@/shared/components'
 import { ChannelFields } from '@/api/queries/__generated__/ChannelFields'
 
 type ChannelGalleryProps = {
@@ -10,26 +10,35 @@ type ChannelGalleryProps = {
   loading?: boolean
 }
 
+const PLACEHOLDERS_COUNT = 12
+
 const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, action, channels, loading }) => {
-  if (loading || !channels) {
-    return <p>Loading</p>
-  }
+  const displayPlaceholders = loading || !channels
 
   return (
-    <Gallery title={title} action={action}>
-      {channels.map((channel) => (
-        <ChannelPreview
-          name={channel.handle}
-          avatarURL={channel.avatarPhotoURL}
-          views={channel.totalViews}
-          key={channel.id}
-          outerContainerCss={css`
-            margin-right: 1.5rem;
-          `}
-        />
-      ))}
+    <Gallery title={title} action={action} disableControls={displayPlaceholders}>
+      {displayPlaceholders
+        ? Array.from({ length: PLACEHOLDERS_COUNT }).map((_, idx) => (
+            <StyledChannelPreviewBase key={`channel-placeholder-${idx}`} />
+          ))
+        : channels!.map((channel) => (
+            <StyledChannelPreview
+              name={channel.handle}
+              avatarURL={channel.avatarPhotoURL}
+              views={channel.totalViews}
+              key={channel.id}
+            />
+          ))}
     </Gallery>
   )
 }
 
+const StyledChannelPreviewBase = styled(ChannelPreviewBase)`
+  margin-right: 1.5rem;
+`
+
+const StyledChannelPreview = styled(ChannelPreview)`
+  margin-right: 1.5rem;
+`
+
 export default ChannelGallery

+ 1 - 1
packages/app/src/components/Hero.tsx

@@ -17,7 +17,7 @@ const Hero: React.FC<Partial<HeroProps>> = ({ backgroundImg }) => {
         font-size: 18px;
         line-height: 1.33;
         & h1 {
-          ${fluidRange({ prop: 'font-size', fromSize: '40px', toSize: '72px' })};
+          ${fluidRange({ prop: 'fontSize', fromSize: '40px', toSize: '72px' })};
           line-height: 0.94;
         }
       `}

+ 43 - 29
packages/app/src/components/VideoGallery.tsx

@@ -1,10 +1,11 @@
 import React, { useCallback, useMemo, useState } from 'react'
 import { css, SerializedStyles } from '@emotion/core'
+import styled from '@emotion/styled'
 import { navigate } from '@reach/router'
 
-import { Gallery, VideoPreview } from '@/shared/components'
-import theme from '@/shared/theme'
+import { Gallery, VideoPreview, VideoPreviewBase } from '@/shared/components'
 import { VideoFields } from '@/api/queries/__generated__/VideoFields'
+import { CAROUSEL_CONTROL_SIZE } from '@/shared/components/Carousel'
 
 type VideoGalleryProps = {
   title: string
@@ -13,12 +14,7 @@ type VideoGalleryProps = {
   loading?: boolean
 }
 
-const articleStyles = css`
-  max-width: 320px;
-  margin-right: 1.25rem;
-`
-
-const CAROUSEL_WHEEL_HEIGHT = theme.sizes.b12
+const PLACEHOLDERS_COUNT = 12
 
 const VideoGallery: React.FC<VideoGalleryProps> = ({ title, action, videos, loading }) => {
   const [posterSize, setPosterSize] = useState(0)
@@ -29,12 +25,14 @@ const VideoGallery: React.FC<VideoGalleryProps> = ({ title, action, videos, load
       return
     }
 
-    const topPx = posterSize / 2 - CAROUSEL_WHEEL_HEIGHT / 2
+    const topPx = posterSize / 2 - CAROUSEL_CONTROL_SIZE / 2
     setGalleryControlCss(css`
       top: ${topPx}px;
     `)
   }, [posterSize])
 
+  const displayPlaceholders = loading || !videos
+
   const imgRef = useCallback((node: HTMLImageElement) => {
     if (node != null) {
       setPosterSize(node.clientHeight)
@@ -45,29 +43,45 @@ const VideoGallery: React.FC<VideoGalleryProps> = ({ title, action, videos, load
     navigate('/video/fake')
   }
 
-  if (loading || !videos) {
-    return <p>Loading</p>
-  }
-
   return (
-    <Gallery title={title} action={action} leftControlCss={galleryControlCss} rightControlCss={galleryControlCss}>
-      {videos.map((video, idx) => (
-        <article css={articleStyles} key={`${title}- ${video.title} - ${idx}`}>
-          <VideoPreview
-            title={video.title}
-            channelName={video.channel.handle}
-            channelAvatarURL={video.channel.avatarPhotoURL}
-            views={video.views}
-            createdAt={video.publishedOnJoystreamAt}
-            duration={video.duration}
-            posterURL={video.thumbnailURL}
-            onClick={handleVideoClick}
-            imgRef={idx === 0 ? imgRef : null}
-          />
-        </article>
-      ))}
+    <Gallery
+      title={title}
+      action={action}
+      leftControlCss={galleryControlCss}
+      rightControlCss={galleryControlCss}
+      disableControls={displayPlaceholders}
+    >
+      {displayPlaceholders
+        ? Array.from({ length: PLACEHOLDERS_COUNT }).map((_, idx) => (
+            <StyledVideoPreviewBase key={`video-placeholder-${idx}`} />
+          ))
+        : videos!.map((video, idx) => (
+            <StyledVideoPreview
+              title={video.title}
+              channelName={video.channel.handle}
+              channelAvatarURL={video.channel.avatarPhotoURL}
+              views={video.views}
+              createdAt={video.publishedOnJoystreamAt}
+              duration={video.duration}
+              posterURL={video.thumbnailURL}
+              onClick={handleVideoClick}
+              imgRef={idx === 0 ? imgRef : null}
+              key={video.id}
+            />
+          ))}
     </Gallery>
   )
 }
 
+const StyledVideoPreviewBase = styled(VideoPreviewBase)`
+  & + & {
+    margin-left: 1.25rem;
+  }
+`
+const StyledVideoPreview = styled(VideoPreview)`
+  & + & {
+    margin-left: 1.25rem;
+  }
+`
+
 export default VideoGallery

+ 4 - 4
packages/app/src/mocking/data/mockImages.ts

@@ -1,8 +1,8 @@
 export const channelSources = [
-  'https://source.unsplash.com/collection/781477/160x160',
-  'https://source.unsplash.com/collection/895539/160x160',
-  'https://source.unsplash.com/collection/162326/160x160',
-  'https://source.unsplash.com/collection/472913/160x160',
+  'https://source.unsplash.com/collection/781477/320x320',
+  'https://source.unsplash.com/collection/895539/320x320',
+  'https://source.unsplash.com/collection/162326/320x320',
+  'https://source.unsplash.com/collection/472913/320x320',
 ]
 
 export const posterSources = [

+ 1 - 1
packages/app/src/mocking/server.ts

@@ -26,7 +26,7 @@ createServer({
       },
     })
 
-    this.post('/graphql', graphQLHandler)
+    this.post('/graphql', graphQLHandler, { timing: 1500 }) // include load delay
   },
 
   seeds(server) {

+ 9 - 6
packages/app/src/shared/components/Carousel/Carousel.style.ts

@@ -1,8 +1,11 @@
 import { makeStyles, StyleFn } from '../../utils'
 import { spacing } from '../../theme'
+import theme from '@/shared/theme'
 
 export type CarouselStyleProps = Record<string, unknown>
 
+export const CAROUSEL_CONTROL_SIZE = theme.sizes.b12
+
 const container: StyleFn = () => ({
   position: 'relative',
   display: 'flex',
@@ -18,23 +21,23 @@ const innerItemsContainer: StyleFn = () => ({
 })
 
 const navBase: StyleFn = () => ({
-  minWidth: spacing.xxxxl,
-  minHeight: spacing.xxxxl,
-  width: spacing.xxxxl,
-  height: spacing.xxxxl,
+  minWidth: `${CAROUSEL_CONTROL_SIZE}px`,
+  minHeight: `${CAROUSEL_CONTROL_SIZE}px`,
+  width: `${CAROUSEL_CONTROL_SIZE}px`,
+  height: `${CAROUSEL_CONTROL_SIZE}px`,
   position: 'absolute',
 })
 
 const navLeft: StyleFn = (styles) => ({
   ...styles,
   left: 0,
-  top: `calc(50% - ${Math.round((parseInt(spacing.xxxxl) + 1) / 2)}px)`,
+  top: `calc(50% - ${Math.round((CAROUSEL_CONTROL_SIZE + 1) / 2)}px)`,
 })
 
 const navRight: StyleFn = (styles) => ({
   ...styles,
   right: 0,
-  top: `calc(50% - ${Math.round((parseInt(spacing.xxxxl) + 1) / 2)}px)`,
+  top: `calc(50% - ${Math.round((CAROUSEL_CONTROL_SIZE + 1) / 2)}px)`,
 })
 
 export const useCSS = (props: CarouselStyleProps) => ({

+ 44 - 70
packages/app/src/shared/components/Carousel/Carousel.tsx

@@ -1,45 +1,41 @@
-import React, { useState, useRef, useEffect } from 'react'
-import { css, SerializedStyles } from '@emotion/core'
+import React, { useState } from 'react'
+import { SerializedStyles } from '@emotion/core'
 import { animated, useSpring } from 'react-spring'
 import useResizeObserver from 'use-resize-observer'
-import { useCSS, CarouselStyleProps } from './Carousel.style'
+import { CarouselStyleProps, useCSS } from './Carousel.style'
 import NavButton from '../NavButton'
 
-type CarouselProps = {
-  children: React.ReactNode
-  containerCss: SerializedStyles
-  leftControlCss: SerializedStyles
-  rightControlCss: SerializedStyles
-  onScroll: (direction: 'left' | 'right') => void
+export type CarouselProps = {
+  containerCss?: SerializedStyles
+  leftControlCss?: SerializedStyles
+  rightControlCss?: SerializedStyles
+  disableControls?: boolean
+  onScroll?: (direction: 'left' | 'right') => void
 } & CarouselStyleProps
 
-const Carousel: React.FC<Partial<CarouselProps>> = ({
+const Carousel: React.FC<CarouselProps> = ({
   children,
   containerCss,
   leftControlCss,
   rightControlCss,
+  disableControls = false,
   onScroll = () => {},
 }) => {
   const [scroll, setScroll] = useSpring(() => ({
     transform: `translateX(0px)`,
   }))
-  const [x, setX] = useState(0)
-  const { width: containerWidth = NaN, ref: containerRef } = useResizeObserver<HTMLDivElement>()
-  const elementsRefs = useRef<(HTMLDivElement | null)[]>([])
-  const [childrensWidth, setChildrensWidth] = useState(0)
-  useEffect(() => {
-    if (Array.isArray(children)) {
-      elementsRefs.current = elementsRefs.current.slice(0, children.length)
-      const childrensWidth = elementsRefs.current.reduce(
-        (accWidth, el) => (el != null ? accWidth + el.clientWidth : accWidth),
-        0
-      )
-      setChildrensWidth(childrensWidth)
-    }
-  }, [children])
+  const [carouselOffset, setCarouselOffset] = useState(0)
+  const { width: containerWidth = 0, ref: containerRef } = useResizeObserver<HTMLDivElement>()
+  const { width: childrenWidth = 0, ref: childrenContainerRef } = useResizeObserver<HTMLDivElement>()
+
   const styles = useCSS({})
 
-  function handleScroll(direction: 'left' | 'right') {
+  const maxScrollOffset = childrenWidth - containerWidth
+
+  const showLeftControl = !disableControls && carouselOffset > 0
+  const showRightControl = !disableControls && carouselOffset < maxScrollOffset
+
+  const handleScroll = (direction: 'left' | 'right') => {
     if (containerWidth == null) {
       return
     }
@@ -47,72 +43,50 @@ const Carousel: React.FC<Partial<CarouselProps>> = ({
     switch (direction) {
       case 'left': {
         // Prevent overscroll on the left
-        scrollAmount = x + containerWidth >= 0 ? 0 : x + containerWidth
+        const newOffset = carouselOffset - containerWidth
+        scrollAmount = newOffset < 0 ? 0 : newOffset
         onScroll('left')
         break
       }
       case 'right': {
         // Prevent overscroll on the right
-        scrollAmount =
-          x - containerWidth <= -(childrensWidth - containerWidth)
-            ? -(childrensWidth - containerWidth)
-            : x - containerWidth
+        const newOffset = carouselOffset + containerWidth
+        scrollAmount = newOffset > maxScrollOffset ? maxScrollOffset : newOffset
         onScroll('right')
         break
       }
     }
-    setX(scrollAmount)
+    setCarouselOffset(scrollAmount)
     setScroll({
-      transform: `translateX(${scrollAmount}px)`,
+      transform: `translateX(-${scrollAmount}px)`,
     })
   }
 
   if (!Array.isArray(children)) {
     return <>{children}</>
   }
+
   return (
     <div css={[styles.container, containerCss]}>
       <div css={styles.outerItemsContainer} ref={containerRef}>
-        <animated.div style={scroll} css={styles.innerItemsContainer}>
-          {children.map((element, idx) => (
-            <div
-              key={`Carousel-${idx}`}
-              ref={(el) => {
-                elementsRefs.current[idx] = el
-                return el
-              }}
-            >
-              {element}
-            </div>
-          ))}
+        <animated.div css={styles.innerItemsContainer} style={scroll}>
+          <div css={styles.innerItemsContainer} ref={childrenContainerRef}>
+            {children.map((element, idx) => (
+              <React.Fragment key={`Carousel-${idx}`}>{element}</React.Fragment>
+            ))}
+          </div>
         </animated.div>
       </div>
-      <NavButton
-        outerCss={[
-          styles.navLeft,
-          css`
-            opacity: ${x === 0 ? 0 : 1};
-          `,
-          leftControlCss,
-        ]}
-        direction="left"
-        onClick={() => {
-          handleScroll('left')
-        }}
-      />
-      <NavButton
-        outerCss={[
-          styles.navRight,
-          css`
-            opacity: ${x === -(childrensWidth - containerWidth) ? 0 : 1};
-          `,
-          rightControlCss,
-        ]}
-        direction="right"
-        onClick={() => {
-          handleScroll('right')
-        }}
-      />
+      {showLeftControl && (
+        <NavButton outerCss={[styles.navLeft, leftControlCss]} direction="left" onClick={() => handleScroll('left')} />
+      )}
+      {showRightControl && (
+        <NavButton
+          outerCss={[styles.navRight, rightControlCss]}
+          direction="right"
+          onClick={() => handleScroll('right')}
+        />
+      )}
     </div>
   )
 }

+ 5 - 2
packages/app/src/shared/components/Carousel/index.ts

@@ -1,2 +1,5 @@
-import Carousel from './Carousel'
-export default Carousel
+import Carousel, { CarouselProps } from './Carousel'
+import { CAROUSEL_CONTROL_SIZE } from './Carousel.style'
+
+export { Carousel as default, CAROUSEL_CONTROL_SIZE }
+export type { CarouselProps }

+ 0 - 52
packages/app/src/shared/components/ChannelPreview/ChannelPreview.style.tsx

@@ -1,52 +0,0 @@
-import { makeStyles, StyleFn } from '../../utils'
-import { colors } from '../../theme'
-
-export type ChannelPreviewStyleProps = {
-  channelAvatar: string
-  width: number
-  height: number
-}
-
-const imageTopOverflow = '2rem'
-
-const outerContainer: StyleFn = (_, { width = 200, height = 186 }) => ({
-  width,
-  height: `calc(${height}px + ${imageTopOverflow})`,
-  paddingTop: imageTopOverflow,
-})
-
-const innerContainer: StyleFn = () => ({
-  backgroundColor: colors.gray[800],
-  color: colors.gray[300],
-  display: 'flex',
-  flexDirection: 'column',
-  justifyContent: 'flex-end',
-})
-
-const info: StyleFn = () => ({
-  margin: `12px auto 10px`,
-  textAlign: 'center',
-  '& > h2': {
-    margin: 0,
-    fontSize: '1rem',
-  },
-  '& > span': {
-    fontSize: '0.875rem',
-    lineHeight: 1.43,
-  },
-})
-
-const avatar: StyleFn = () => ({
-  width: 156,
-  height: 156,
-  position: 'relative',
-  margin: `-${imageTopOverflow} auto 0`,
-  zIndex: 2,
-})
-
-export const useCSS = (props: Partial<ChannelPreviewStyleProps>) => ({
-  outerContainer: makeStyles([outerContainer])(props),
-  innerContainer: makeStyles([innerContainer])(props),
-  info: makeStyles([info])(props),
-  avatar: makeStyles([avatar])(props),
-})

+ 22 - 16
packages/app/src/shared/components/ChannelPreview/ChannelPreview.tsx

@@ -1,27 +1,33 @@
 import React from 'react'
-import { SerializedStyles } from '@emotion/core'
-import { useCSS } from './ChannelPreview.style'
+import styled from '@emotion/styled'
 import Avatar from '../Avatar'
 import { formatNumberShort } from '@/utils/number'
+import ChannelPreviewBase from './ChannelPreviewBase'
+import { typography } from '../../theme'
 
 type ChannelPreviewProps = {
   name: string
   views: number
   avatarURL?: string
-  outerContainerCss?: SerializedStyles
+  className?: string
 }
 
-export default function ChannelPreview({ name, avatarURL, views, outerContainerCss }: ChannelPreviewProps) {
-  const styles = useCSS({})
-  return (
-    <article css={[styles.outerContainer, outerContainerCss]}>
-      <div css={styles.innerContainer}>
-        <Avatar outerStyles={styles.avatar} img={avatarURL} />
-        <div css={styles.info}>
-          <h2>{name}</h2>
-          <span>{formatNumberShort(views)} views</span>
-        </div>
-      </div>
-    </article>
-  )
+const ChannelPreview: React.FC<ChannelPreviewProps> = ({ name, avatarURL, views, className }) => {
+  const avatarNode = <Avatar img={avatarURL} />
+  const nameNode = <NameHeader>{name}</NameHeader>
+  const metaNode = <MetaSpan>{formatNumberShort(views)} views</MetaSpan>
+
+  return <ChannelPreviewBase className={className} avatarNode={avatarNode} nameNode={nameNode} metaNode={metaNode} />
 }
+
+const NameHeader = styled.h2`
+  margin: 0;
+  font-size: ${typography.sizes.h6};
+`
+
+const MetaSpan = styled.span`
+  font-size: ${typography.sizes.subtitle2};
+  line-height: 1.43;
+`
+
+export default ChannelPreview

+ 35 - 0
packages/app/src/shared/components/ChannelPreview/ChannelPreviewBase.style.tsx

@@ -0,0 +1,35 @@
+import { colors } from '../../theme'
+import styled from '@emotion/styled'
+
+const imageTopOverflow = '2rem'
+
+export const OuterContainer = styled.article`
+  width: 200px;
+  height: ${`calc(186px + ${imageTopOverflow})`};
+  padding-top: ${imageTopOverflow};
+`
+
+export const InnerContainer = styled.div`
+  background-color: ${colors.gray[800]};
+  color: ${colors.gray[300]};
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+`
+
+export const Info = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+
+  margin: 12px auto 10px;
+`
+
+export const AvatarContainer = styled.div`
+  width: 156px;
+  height: 156px;
+  position: relative;
+  margin: -${imageTopOverflow} auto 0;
+  z-index: 2;
+`

+ 35 - 0
packages/app/src/shared/components/ChannelPreview/ChannelPreviewBase.tsx

@@ -0,0 +1,35 @@
+import React from 'react'
+import styled from '@emotion/styled'
+import { AvatarContainer, Info, InnerContainer, OuterContainer } from './ChannelPreviewBase.style'
+import Placeholder from '../Placeholder'
+
+type ChannelPreviewBaseProps = {
+  avatarNode?: React.ReactNode
+  nameNode?: React.ReactNode
+  metaNode?: React.ReactNode
+  className?: string
+}
+
+const ChannelPreviewBase: React.FC<ChannelPreviewBaseProps> = ({ avatarNode, nameNode, metaNode, className }) => {
+  const avatarPlaceholder = <Placeholder rounded />
+  const namePlaceholder = <Placeholder width="140px" height="16px" />
+  const metaPlaceholder = <MetaPlaceholder width="80px" height="12px" />
+
+  return (
+    <OuterContainer className={className}>
+      <InnerContainer>
+        <AvatarContainer>{avatarNode || avatarPlaceholder}</AvatarContainer>
+        <Info>
+          {nameNode || namePlaceholder}
+          {metaNode || metaPlaceholder}
+        </Info>
+      </InnerContainer>
+    </OuterContainer>
+  )
+}
+
+const MetaPlaceholder = styled(Placeholder)`
+  margin-top: 6px;
+`
+
+export default ChannelPreviewBase

+ 3 - 1
packages/app/src/shared/components/ChannelPreview/index.ts

@@ -1,2 +1,4 @@
 import ChannelPreview from './ChannelPreview'
-export default ChannelPreview
+import ChannelPreviewBase from './ChannelPreviewBase'
+
+export { ChannelPreview, ChannelPreviewBase }

+ 8 - 20
packages/app/src/shared/components/Gallery/Gallery.tsx

@@ -2,26 +2,16 @@ import React from 'react'
 import { SerializedStyles } from '@emotion/core'
 import { useCSS } from './Gallery.style'
 import Button from '../Button'
-import Carousel from '../Carousel'
+import Carousel, { CarouselProps } from '../Carousel'
 
 type GalleryProps = {
-  title: string
-  action: string
-  onClick: () => void
-  containerCss: SerializedStyles
-  leftControlCss: SerializedStyles
-  rightControlCss: SerializedStyles
-}
+  title?: string
+  action?: string
+  onClick?: () => void
+  containerCss?: SerializedStyles
+} & CarouselProps
 
-const Gallery: React.FC<Partial<GalleryProps>> = ({
-  title,
-  onClick,
-  action = '',
-  children,
-  containerCss,
-  leftControlCss,
-  rightControlCss,
-}) => {
+const Gallery: React.FC<GalleryProps> = ({ title, action = '', containerCss, onClick, ...props }) => {
   const styles = useCSS()
   return (
     <section css={[styles.container, containerCss]}>
@@ -33,9 +23,7 @@ const Gallery: React.FC<Partial<GalleryProps>> = ({
           </Button>
         )}
       </div>
-      <Carousel leftControlCss={leftControlCss} rightControlCss={rightControlCss}>
-        {children}
-      </Carousel>
+      <Carousel {...props} />
     </section>
   )
 }

+ 17 - 0
packages/app/src/shared/components/Placeholder/Placeholder.tsx

@@ -0,0 +1,17 @@
+import styled from '@emotion/styled'
+import { colors } from '@/shared/theme'
+
+type PlaceholderProps = {
+  width?: string
+  height?: string
+  rounded?: boolean
+}
+
+const Placeholder = styled.div<PlaceholderProps>`
+  width: ${({ width = '100%' }) => width};
+  height: ${({ height = '100%' }) => height};
+  border-radius: ${({ rounded = false }) => (rounded ? '100%' : '0')};
+  background-color: ${colors.gray['400']};
+`
+
+export default Placeholder

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

@@ -0,0 +1 @@
+export { default } from './Placeholder'

+ 3 - 61
packages/app/src/shared/components/VideoPreview/VideoPreview.styles.tsx

@@ -2,30 +2,14 @@ import styled from '@emotion/styled'
 import { colors, spacing, typography } from '../../theme'
 import Avatar from '../Avatar'
 import { PlayIcon } from '../../icons'
-
-const HOVER_BORDER_SIZE = '2px'
+import { HOVER_BORDER_SIZE } from './VideoPreviewBase.styles'
 
 type CoverImageProps = Record<string, unknown>
 
-type ContainerProps = {
-  clickable: boolean
-}
-
 type ChannelProps = {
   channelClickable: boolean
 }
 
-export const CoverContainer = styled.div`
-  width: 320px;
-  height: 190px;
-
-  transition-property: box-shadow, transform;
-  transition-duration: 0.4s;
-  transition-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1);
-
-  position: relative;
-`
-
 export const CoverImage = styled.img<CoverImageProps>`
   width: 100%;
   height: 100%;
@@ -77,34 +61,6 @@ export const ProgressBar = styled.div`
   background-color: ${colors.blue['500']};
 `
 
-export const Container = styled.div<ContainerProps>`
-  color: ${colors.gray[300]};
-  cursor: ${({ clickable }) => (clickable ? 'pointer' : 'auto')};
-  display: inline-block;
-  ${({ clickable }) =>
-    clickable &&
-    `
-				&:hover {
-					${CoverContainer} {
-						transform: translate(-${spacing.xs}, -${spacing.xs});
-						box-shadow: ${spacing.xs} ${spacing.xs} 0 ${colors.blue['500']};
-					}
-
-					${CoverHoverOverlay} {
-						opacity: 1;
-					}
-					
-					${CoverPlayIcon} {
-						transform: translateY(0);
-					}
-
-					${ProgressOverlay} {
-						bottom: ${HOVER_BORDER_SIZE};
-					}
-				}
-			`}
-`
-
 export const CoverDurationOverlay = styled.div`
   position: absolute;
   bottom: ${spacing.xs};
@@ -115,25 +71,12 @@ export const CoverDurationOverlay = styled.div`
   font-size: ${typography.sizes.body2};
 `
 
-export const InfoContainer = styled.div`
-  display: flex;
-  margin-top: ${spacing.s};
-`
-
 export const StyledAvatar = styled(Avatar)<ChannelProps>`
-  width: 40px;
-  min-width: 40px;
-  height: 40px;
-  margin-right: ${spacing.xs};
+  width: 100%;
+  height: 100%;
   cursor: ${({ channelClickable }) => (channelClickable ? 'pointer' : 'auto')};
 `
 
-export const TextContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  align-items: start;
-`
-
 export const TitleHeader = styled.h3`
   margin: 0;
   font-weight: ${typography.weights.bold};
@@ -151,6 +94,5 @@ export const ChannelName = styled.span<ChannelProps>`
 `
 
 export const MetaText = styled.span`
-  margin-top: ${spacing.xs};
   font-size: ${typography.sizes.subtitle2};
 `

+ 51 - 43
packages/app/src/shared/components/VideoPreview/VideoPreview.tsx

@@ -1,22 +1,19 @@
 import React from 'react'
 import {
   ChannelName,
-  Container,
-  CoverContainer,
   CoverDurationOverlay,
   CoverHoverOverlay,
   CoverImage,
   CoverPlayIcon,
-  InfoContainer,
   MetaText,
   ProgressBar,
   ProgressOverlay,
   StyledAvatar,
-  TextContainer,
   TitleHeader,
 } from './VideoPreview.styles'
 import { formatDateAgo, formatDurationShort } from '@/utils/time'
 import { formatNumberShort } from '@/utils/number'
+import VideoPreviewBase from './VideoPreviewBase'
 
 type VideoPreviewProps = {
   title: string
@@ -53,7 +50,6 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
   onChannelClick,
   className,
 }) => {
-  const clickable = !!onClick
   const channelClickable = !!onChannelClick
 
   const handleChannelClick = (e: React.MouseEvent<HTMLElement>) => {
@@ -72,45 +68,57 @@ const VideoPreview: React.FC<VideoPreviewProps> = ({
     onClick(e)
   }
 
+  const coverNode = (
+    <>
+      <CoverImage src={posterURL} ref={imgRef} alt={`${title} by ${channelName} thumbnail`} />
+      {duration && <CoverDurationOverlay>{formatDurationShort(duration)}</CoverDurationOverlay>}
+      {!!progress && (
+        <ProgressOverlay>
+          <ProgressBar style={{ width: `${progress}%` }} />
+        </ProgressOverlay>
+      )}
+      <CoverHoverOverlay>
+        <CoverPlayIcon />
+      </CoverHoverOverlay>
+    </>
+  )
+
+  const titleNode = <TitleHeader>{title}</TitleHeader>
+
+  const channelAvatarNode = (
+    <StyledAvatar
+      size="small"
+      name={channelName}
+      img={channelAvatarURL}
+      channelClickable={channelClickable}
+      onClick={handleChannelClick}
+    />
+  )
+
+  const channelNameNode = (
+    <ChannelName channelClickable={channelClickable} onClick={handleChannelClick}>
+      {channelName}
+    </ChannelName>
+  )
+
+  const metaNode = (
+    <MetaText>
+      {formatDateAgo(createdAt)}・{formatNumberShort(views)} views
+    </MetaText>
+  )
+
   return (
-    <Container onClick={handleClick} clickable={clickable} className={className}>
-      <CoverContainer>
-        <CoverImage src={posterURL} ref={imgRef} alt={`${title} by ${channelName} thumbnail`} />
-        {duration && <CoverDurationOverlay>{formatDurationShort(duration)}</CoverDurationOverlay>}
-        {!!progress && (
-          <ProgressOverlay>
-            <ProgressBar style={{ width: `${progress}%` }} />
-          </ProgressOverlay>
-        )}
-        <CoverHoverOverlay>
-          <CoverPlayIcon />
-        </CoverHoverOverlay>
-      </CoverContainer>
-      <InfoContainer>
-        {showChannel && (
-          <StyledAvatar
-            size="small"
-            name={channelName}
-            img={channelAvatarURL}
-            channelClickable={channelClickable}
-            onClick={handleChannelClick}
-          />
-        )}
-        <TextContainer>
-          <TitleHeader>{title}</TitleHeader>
-          {showChannel && (
-            <ChannelName channelClickable={channelClickable} onClick={handleChannelClick}>
-              {channelName}
-            </ChannelName>
-          )}
-          {showMeta && (
-            <MetaText>
-              {formatDateAgo(createdAt)}・{formatNumberShort(views)} views
-            </MetaText>
-          )}
-        </TextContainer>
-      </InfoContainer>
-    </Container>
+    <VideoPreviewBase
+      coverNode={coverNode}
+      titleNode={titleNode}
+      showChannel={showChannel}
+      channelAvatarNode={channelAvatarNode}
+      channelNameNode={channelNameNode}
+      showMeta={showMeta}
+      metaNode={metaNode}
+      onClick={handleClick}
+      className={className}
+    />
   )
 }
 

+ 72 - 0
packages/app/src/shared/components/VideoPreview/VideoPreviewBase.styles.tsx

@@ -0,0 +1,72 @@
+import styled from '@emotion/styled'
+import { colors, spacing } from '@/shared/theme'
+import { CoverHoverOverlay, CoverPlayIcon, ProgressOverlay } from './VideoPreview.styles'
+
+export const HOVER_BORDER_SIZE = '2px'
+
+type ContainerProps = {
+  clickable: boolean
+}
+
+export const CoverContainer = styled.div`
+  width: 320px;
+  height: 190px;
+
+  transition-property: box-shadow, transform;
+  transition-duration: 0.4s;
+  transition-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1);
+
+  position: relative;
+`
+
+export const Container = styled.article<ContainerProps>`
+  color: ${colors.gray[300]};
+  cursor: ${({ clickable }) => (clickable ? 'pointer' : 'auto')};
+  display: inline-block;
+  ${({ clickable }) =>
+    clickable &&
+    `
+				&:hover {
+					${CoverContainer} {
+						transform: translate(-${spacing.xs}, -${spacing.xs});
+						box-shadow: ${spacing.xs} ${spacing.xs} 0 ${colors.blue['500']};
+					}
+
+					${CoverHoverOverlay} {
+						opacity: 1;
+					}
+					
+					${CoverPlayIcon} {
+						transform: translateY(0);
+					}
+
+					${ProgressOverlay} {
+						bottom: ${HOVER_BORDER_SIZE};
+					}
+				}
+			`}
+`
+
+export const InfoContainer = styled.div`
+  display: flex;
+  margin-top: ${spacing.s};
+`
+
+export const AvatarContainer = styled.div`
+  width: 40px;
+  min-width: 40px;
+  height: 40px;
+  margin-right: ${spacing.xs};
+`
+
+export const TextContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: start;
+  width: 100%;
+`
+
+export const MetaContainer = styled.div`
+  margin-top: ${spacing.xs};
+  width: 100%;
+`

+ 63 - 0
packages/app/src/shared/components/VideoPreview/VideoPreviewBase.tsx

@@ -0,0 +1,63 @@
+import React from 'react'
+import {
+  AvatarContainer,
+  Container,
+  CoverContainer,
+  InfoContainer,
+  MetaContainer,
+  TextContainer,
+} from './VideoPreviewBase.styles'
+import styled from '@emotion/styled'
+import Placeholder from '../Placeholder'
+
+type VideoPreviewBaseProps = {
+  coverNode?: React.ReactNode
+  titleNode?: React.ReactNode
+  showChannel?: boolean
+  channelAvatarNode?: React.ReactNode
+  channelNameNode?: React.ReactNode
+  showMeta?: boolean
+  metaNode?: React.ReactNode
+  onClick?: (e: React.MouseEvent<HTMLElement>) => void
+  className?: string
+}
+
+const VideoPreviewBase: React.FC<VideoPreviewBaseProps> = ({
+  coverNode,
+  titleNode,
+  showChannel = true,
+  channelAvatarNode,
+  channelNameNode,
+  showMeta = true,
+  metaNode,
+  onClick,
+  className,
+}) => {
+  const clickable = !!onClick
+
+  const coverPlaceholder = <Placeholder />
+  const channelAvatarPlaceholder = <Placeholder rounded />
+  const titlePlaceholder = <Placeholder height="18px" width="60%" />
+  const channelNamePlaceholder = <SpacedPlaceholder height="12px" width="60%" />
+  const metaPlaceholder = <SpacedPlaceholder height="12px" width="80%" />
+
+  return (
+    <Container onClick={onClick} clickable={clickable} className={className}>
+      <CoverContainer>{coverNode || coverPlaceholder}</CoverContainer>
+      <InfoContainer>
+        {showChannel && <AvatarContainer>{channelAvatarNode || channelAvatarPlaceholder}</AvatarContainer>}
+        <TextContainer>
+          {titleNode || titlePlaceholder}
+          {showChannel && (channelNameNode || channelNamePlaceholder)}
+          {showMeta && <MetaContainer>{metaNode || metaPlaceholder}</MetaContainer>}
+        </TextContainer>
+      </InfoContainer>
+    </Container>
+  )
+}
+
+const SpacedPlaceholder = styled(Placeholder)`
+  margin-top: 6px;
+`
+
+export default VideoPreviewBase

+ 3 - 1
packages/app/src/shared/components/VideoPreview/index.tsx

@@ -1,2 +1,4 @@
 import VideoPreview from './VideoPreview'
-export default VideoPreview
+import VideoPreviewBase from './VideoPreviewBase'
+
+export { VideoPreview, VideoPreviewBase }

+ 3 - 2
packages/app/src/shared/components/index.ts

@@ -14,12 +14,13 @@ export { default as Tab } from './Tabs/Tab'
 export { default as Tag } from './Tag'
 export { default as TextField } from './TextField'
 export { default as Typography } from './Typography'
-export { default as VideoPreview } from './VideoPreview'
+export { VideoPreview, VideoPreviewBase } from './VideoPreview'
 export { default as VideoPlayer } from './VideoPlayer'
 export { default as SeriesPreview } from './SeriesPreview'
-export { default as ChannelPreview } from './ChannelPreview'
+export { ChannelPreview, ChannelPreviewBase } from './ChannelPreview'
 export { default as HamburgerButton } from './HamburgerButton'
 export { default as Gallery } from './Gallery'
 export { default as Sidenav, SIDENAV_WIDTH, EXPANDED_SIDENAV_WIDTH, NavItem } from './Sidenav'
 export { default as ChannelAvatar } from './ChannelAvatar'
 export { default as GlobalStyle } from './GlobalStyle'
+export { default as Placeholder } from './Placeholder'

+ 0 - 0
packages/app/src/shared/stories/13-VideoPreview.stories.tsx


+ 5 - 1
packages/app/src/shared/stories/16-VideoPreview.stories.tsx

@@ -1,5 +1,5 @@
 import React from 'react'
-import { VideoPreview } from '../components'
+import { VideoPreview, VideoPreviewBase } from '../components'
 import { boolean, number, text, withKnobs } from '@storybook/addon-knobs'
 import { action } from '@storybook/addon-actions'
 import { formatISO, parseISO, subWeeks } from 'date-fns'
@@ -32,3 +32,7 @@ export const Primary = () => {
     />
   )
 }
+
+export const Placeholder = () => {
+  return <VideoPreviewBase />
+}

+ 23 - 0
packages/app/src/shared/stories/17-ChannelPreview.stories.tsx

@@ -0,0 +1,23 @@
+import React from 'react'
+import { ChannelPreview, ChannelPreviewBase } from '../components'
+import { number, text, withKnobs } from '@storybook/addon-knobs'
+
+export default {
+  title: 'ChannelPreview',
+  component: ChannelPreview,
+  decorators: [withKnobs],
+}
+
+export const Primary = () => {
+  return (
+    <ChannelPreview
+      name={text('Channel name', 'Test channel')}
+      views={number('Channel views', 123456)}
+      avatarURL={text('Channel avatar URL', 'https://source.unsplash.com/collection/781477/320x320')}
+    />
+  )
+}
+
+export const Placeholder = () => {
+  return <ChannelPreviewBase />
+}

+ 1 - 1
packages/app/src/shared/theme/typography.ts

@@ -19,7 +19,7 @@ export default {
     h5: '1.25rem',
     h6: '1rem',
     subtitle1: '1.1rem',
-    subtitle2: '0.9rem',
+    subtitle2: '0.875rem',
     body1: '1.1rem',
     body2: '0.9rem',
     caption: '0.8rem',