Browse Source

Add Search, Navbar and SearchView

Francesco Baccetti 4 years ago
parent
commit
f827ddbf0d
36 changed files with 636 additions and 150 deletions
  1. 0 12
      packages/app/__generated__/globalTypes.ts
  2. 3 0
      packages/app/src/api/client.ts
  3. 25 27
      packages/app/src/api/queries/__generated__/GetFeaturedVideos.ts
  4. 27 29
      packages/app/src/api/queries/__generated__/GetNewestVideos.ts
  5. 72 0
      packages/app/src/api/queries/__generated__/Search.ts
  6. 24 26
      packages/app/src/api/queries/__generated__/VideoFields.ts
  7. 1 1
      packages/app/src/api/queries/channels.ts
  8. 1 0
      packages/app/src/api/queries/index.ts
  9. 21 0
      packages/app/src/api/queries/search.ts
  10. 1 1
      packages/app/src/api/queries/videos.ts
  11. 19 0
      packages/app/src/assets/logo.svg
  12. 6 1
      packages/app/src/components/LayoutWithRouting.tsx
  13. 56 0
      packages/app/src/components/Navbar/Navbar.style.tsx
  14. 61 0
      packages/app/src/components/Navbar/Navbar.tsx
  15. 2 0
      packages/app/src/components/Navbar/index.ts
  16. 39 0
      packages/app/src/components/VideoBestMatch/VideoBestMatch.style.tsx
  17. 32 0
      packages/app/src/components/VideoBestMatch/VideoBestMatch.tsx
  18. 2 0
      packages/app/src/components/VideoBestMatch/index.ts
  19. 2 0
      packages/app/src/components/index.ts
  20. 2 1
      packages/app/src/config/routes.ts
  21. 27 0
      packages/app/src/mocking/server.ts
  22. 18 39
      packages/app/src/shared/components/NavButton/NavButton.style.ts
  23. 12 6
      packages/app/src/shared/components/NavButton/NavButton.tsx
  24. 17 0
      packages/app/src/shared/components/Searchbar/Searchbar.style.tsx
  25. 31 0
      packages/app/src/shared/components/Searchbar/Searchbar.tsx
  26. 2 0
      packages/app/src/shared/components/Searchbar/index.ts
  27. 23 0
      packages/app/src/shared/components/TabsMenu/TabMenu.styles.tsx
  28. 29 0
      packages/app/src/shared/components/TabsMenu/TabsMenu.tsx
  29. 2 0
      packages/app/src/shared/components/TabsMenu/index.ts
  30. 2 0
      packages/app/src/shared/components/index.ts
  31. 2 0
      packages/app/src/shared/icons/index.ts
  32. 5 0
      packages/app/src/shared/icons/times.svg
  33. 4 4
      packages/app/src/shared/stories/02-NavigationButton.stories.tsx
  34. 3 1
      packages/app/src/utils/time.ts
  35. 61 0
      packages/app/src/views/SearchView.tsx
  36. 2 2
      packages/app/src/views/index.ts

+ 0 - 12
packages/app/__generated__/globalTypes.ts

@@ -1,12 +0,0 @@
-/* tslint:disable */
-/* eslint-disable */
-// @generated
-// This file was automatically generated and should not be edited.
-
-//==============================================================
-// START Enums and Input Objects
-//==============================================================
-
-//==============================================================
-// END Enums and Input Objects
-//==============================================================

+ 3 - 0
packages/app/src/api/client.ts

@@ -22,6 +22,9 @@ const apolloClient = new ApolloClient({
         },
       },
     },
+    possibleTypes: {
+      FreeTextSearchResultItemType: ['Video', 'Channel'],
+    },
   }),
 })
 

+ 25 - 27
packages/app/src/api/queries/__generated__/GetFeaturedVideos.ts

@@ -8,47 +8,45 @@
 // ====================================================
 
 export interface GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation {
-  __typename: 'HTTPVideoMediaLocation'
-  host: string
-  port: number | null
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
 }
 
 export interface GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation {
-  __typename: 'JoystreamVideoMediaLocation'
-  dataObjectID: string
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
 }
 
-export type GetFeaturedVideos_featured_videos_media_location =
-  | GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation
-  | GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation
+export type GetFeaturedVideos_featured_videos_media_location = GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation | GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation;
 
 export interface GetFeaturedVideos_featured_videos_media {
-  __typename: 'VideoMedia'
-  pixelHeight: number
-  pixelWidth: number
-  location: GetFeaturedVideos_featured_videos_media_location
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetFeaturedVideos_featured_videos_media_location;
 }
 
 export interface GetFeaturedVideos_featured_videos_channel {
-  __typename: 'Channel'
-  id: string
-  avatarPhotoURL: string
-  handle: string
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string;
+  handle: string;
 }
 
 export interface GetFeaturedVideos_featured_videos {
-  __typename: 'Video'
-  id: string
-  title: string
-  description: string
-  views: number
-  duration: number
-  thumbnailURL: string
-  publishedOnJoystreamAt: GQLDate
-  media: GetFeaturedVideos_featured_videos_media
-  channel: GetFeaturedVideos_featured_videos_channel
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetFeaturedVideos_featured_videos_media;
+  channel: GetFeaturedVideos_featured_videos_channel;
 }
 
 export interface GetFeaturedVideos {
-  featured_videos: GetFeaturedVideos_featured_videos[]
+  featured_videos: GetFeaturedVideos_featured_videos[];
 }

+ 27 - 29
packages/app/src/api/queries/__generated__/GetNewestVideos.ts

@@ -8,52 +8,50 @@
 // ====================================================
 
 export interface GetNewestVideos_videos_media_location_HTTPVideoMediaLocation {
-  __typename: 'HTTPVideoMediaLocation'
-  host: string
-  port: number | null
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
 }
 
 export interface GetNewestVideos_videos_media_location_JoystreamVideoMediaLocation {
-  __typename: 'JoystreamVideoMediaLocation'
-  dataObjectID: string
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
 }
 
-export type GetNewestVideos_videos_media_location =
-  | GetNewestVideos_videos_media_location_HTTPVideoMediaLocation
-  | GetNewestVideos_videos_media_location_JoystreamVideoMediaLocation
+export type GetNewestVideos_videos_media_location = GetNewestVideos_videos_media_location_HTTPVideoMediaLocation | GetNewestVideos_videos_media_location_JoystreamVideoMediaLocation;
 
 export interface GetNewestVideos_videos_media {
-  __typename: 'VideoMedia'
-  pixelHeight: number
-  pixelWidth: number
-  location: GetNewestVideos_videos_media_location
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetNewestVideos_videos_media_location;
 }
 
 export interface GetNewestVideos_videos_channel {
-  __typename: 'Channel'
-  id: string
-  avatarPhotoURL: string
-  handle: string
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string;
+  handle: string;
 }
 
 export interface GetNewestVideos_videos {
-  __typename: 'Video'
-  id: string
-  title: string
-  description: string
-  views: number
-  duration: number
-  thumbnailURL: string
-  publishedOnJoystreamAt: GQLDate
-  media: GetNewestVideos_videos_media
-  channel: GetNewestVideos_videos_channel
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetNewestVideos_videos_media;
+  channel: GetNewestVideos_videos_channel;
 }
 
 export interface GetNewestVideos {
-  videos: GetNewestVideos_videos[]
+  videos: GetNewestVideos_videos[];
 }
 
 export interface GetNewestVideosVariables {
-  offset?: number | null
-  limit?: number | null
+  offset?: number | null;
+  limit?: number | null;
 }

+ 72 - 0
packages/app/src/api/queries/__generated__/Search.ts

@@ -0,0 +1,72 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: Search
+// ====================================================
+
+export interface Search_search_item_Video_media_location_HTTPVideoMediaLocation {
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
+}
+
+export interface Search_search_item_Video_media_location_JoystreamVideoMediaLocation {
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
+}
+
+export type Search_search_item_Video_media_location = Search_search_item_Video_media_location_HTTPVideoMediaLocation | Search_search_item_Video_media_location_JoystreamVideoMediaLocation;
+
+export interface Search_search_item_Video_media {
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: Search_search_item_Video_media_location;
+}
+
+export interface Search_search_item_Video_channel {
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string;
+  handle: string;
+}
+
+export interface Search_search_item_Video {
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: Search_search_item_Video_media;
+  channel: Search_search_item_Video_channel;
+}
+
+export interface Search_search_item_Channel {
+  __typename: "Channel";
+  id: string;
+  handle: string;
+  avatarPhotoURL: string;
+  totalViews: number;
+}
+
+export type Search_search_item = Search_search_item_Video | Search_search_item_Channel;
+
+export interface Search_search {
+  __typename: "FreeTextSearchResult";
+  item: Search_search_item;
+  rank: number;
+}
+
+export interface Search {
+  search: Search_search[];
+}
+
+export interface SearchVariables {
+  query_string: string;
+}

+ 24 - 26
packages/app/src/api/queries/__generated__/VideoFields.ts

@@ -8,43 +8,41 @@
 // ====================================================
 
 export interface VideoFields_media_location_HTTPVideoMediaLocation {
-  __typename: 'HTTPVideoMediaLocation'
-  host: string
-  port: number | null
+  __typename: "HTTPVideoMediaLocation";
+  host: string;
+  port: number | null;
 }
 
 export interface VideoFields_media_location_JoystreamVideoMediaLocation {
-  __typename: 'JoystreamVideoMediaLocation'
-  dataObjectID: string
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
 }
 
-export type VideoFields_media_location =
-  | VideoFields_media_location_HTTPVideoMediaLocation
-  | VideoFields_media_location_JoystreamVideoMediaLocation
+export type VideoFields_media_location = VideoFields_media_location_HTTPVideoMediaLocation | VideoFields_media_location_JoystreamVideoMediaLocation;
 
 export interface VideoFields_media {
-  __typename: 'VideoMedia'
-  pixelHeight: number
-  pixelWidth: number
-  location: VideoFields_media_location
+  __typename: "VideoMedia";
+  pixelHeight: number;
+  pixelWidth: number;
+  location: VideoFields_media_location;
 }
 
 export interface VideoFields_channel {
-  __typename: 'Channel'
-  id: string
-  avatarPhotoURL: string
-  handle: string
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string;
+  handle: string;
 }
 
 export interface VideoFields {
-  __typename: 'Video'
-  id: string
-  title: string
-  description: string
-  views: number
-  duration: number
-  thumbnailURL: string
-  publishedOnJoystreamAt: GQLDate
-  media: VideoFields_media
-  channel: VideoFields_channel
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: VideoFields_media;
+  channel: VideoFields_channel;
 }

+ 1 - 1
packages/app/src/api/queries/channels.ts

@@ -1,6 +1,6 @@
 import gql from 'graphql-tag'
 
-const channelFieldsFragment = gql`
+export const channelFieldsFragment = gql`
   fragment ChannelFields on Channel {
     id
     handle

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

@@ -1,2 +1,3 @@
 export * from './videos'
 export * from './channels'
+export * from './search'

+ 21 - 0
packages/app/src/api/queries/search.ts

@@ -0,0 +1,21 @@
+import gql from 'graphql-tag'
+import { videoFieldsFragment } from './videos'
+import { channelFieldsFragment } from './channels'
+
+export const SEARCH = gql`
+  query Search($query_string: String!) {
+    search(query_string: $query_string) {
+      item {
+        ... on Video {
+          ...VideoFields
+        }
+        ... on Channel {
+          ...ChannelFields
+        }
+      }
+      rank
+    }
+  }
+  ${channelFieldsFragment}
+  ${videoFieldsFragment}
+`

+ 1 - 1
packages/app/src/api/queries/videos.ts

@@ -1,6 +1,6 @@
 import gql from 'graphql-tag'
 
-const videoFieldsFragment = gql`
+export const videoFieldsFragment = gql`
   fragment VideoFields on Video {
     id
     title

+ 19 - 0
packages/app/src/assets/logo.svg

@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109.61 139.62">
+    <defs>
+        <style>
+            .cls-1 {
+                fill: #4038ff;
+            }
+        </style>
+    </defs>
+    <g id="Layer_2" data-name="Layer 2">
+        <g id="Layer_10" data-name="Layer 10">
+            <path class="cls-1" d="M74,.07,86.75.1l-.16,59.67A57,57,0,0,1,72.1,97.63a67.18,67.18,0,0,0,1.73-15Z" />
+            <path class="cls-1" d="M33,104.13h0A34.14,34.14,0,0,1,6.9,116.75l3.84-12.68Z" />
+            <path class="cls-1" d="M40.8,81.29v1.27a34,34,0,0,1-2,11.42l-25-.07,3.82-12.69Z" />
+            <path class="cls-1" d="M96.91.13l12.7,0L109.51,37A57,57,0,0,1,95,74.86a67.18,67.18,0,0,0,1.73-15Z" />
+            <path class="cls-1"
+                d="M63.88,0l-.22,82.59a57.21,57.21,0,0,1-57.31,57H0l3.83-12.69H6.38A44.51,44.51,0,0,0,51,82.58L51.17,0Z" />
+        </g>
+    </g>
+</svg>

+ 6 - 1
packages/app/src/components/LayoutWithRouting.tsx

@@ -1,15 +1,20 @@
 import React from 'react'
 import { GlobalStyle } from '@/shared/components'
-import { HomeView, VideoView } from '@/views'
+import { Navbar } from '@/components'
+import { HomeView, VideoView, SearchView } from '@/views'
 import routes from '@/config/routes'
 import { Router } from '@reach/router'
 
 const LayoutWithRouting: React.FC = () => (
   <main>
     <GlobalStyle />
+    <Router primary>
+      <Navbar default />
+    </Router>
     <Router primary={false}>
       <HomeView default />
       <VideoView path={routes.video()} />
+      <SearchView path={routes.search()} />
     </Router>
   </main>
 )

+ 56 - 0
packages/app/src/components/Navbar/Navbar.style.tsx

@@ -0,0 +1,56 @@
+import styled from '@emotion/styled'
+
+import { Searchbar, Icon } from '@/shared/components'
+import { colors } from '@/shared/theme'
+import { ReactComponent as UnstyledLogo } from '@/assets/logo.svg'
+
+export const Header = styled.header<{ isSearching: boolean }>`
+  display: grid;
+  grid-template-columns: ${(props) => (props.isSearching ? `134px 1fr 134px` : `repeat(3, 1fr)`)};
+  grid-template-areas: ${(props) => (props.isSearching ? `". searchbar cancel"` : `"navigation searchbar ."`)};
+  width: 100%;
+  padding: ${(props) => (props.isSearching ? '8px' : '12px 32px')};
+  border-bottom: 1px solid ${colors.gray[800]};
+  background-color: ${(props) => (props.isSearching ? colors.gray[900] : colors.black)};
+`
+
+export const Logo = styled(UnstyledLogo)`
+  width: 48px;
+  height: 48px;
+`
+
+export const NavigationContainer = styled.div`
+  display: flex;
+  grid-area: navigation;
+  align-items: center;
+  > * + * {
+    margin-left: 24px;
+  }
+`
+
+export const StyledSearchbar = styled(Searchbar)`
+  width: 100%;
+  grid-area: searchbar;
+`
+
+export const StyledIcon = styled(Icon)`
+  color: ${colors.gray[600]};
+  &:hover {
+    color: ${colors.white};
+    cursor: pointer;
+  }
+`
+
+export const CancelButton = styled.div`
+  width: 48px;
+  height: 48px;
+  color: ${colors.white};
+  grid-area: cancel;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  justify-self: end;
+  :hover {
+    cursor: pointer;
+  }
+`

+ 61 - 0
packages/app/src/components/Navbar/Navbar.tsx

@@ -0,0 +1,61 @@
+import React, { useState } from 'react'
+import { navigate, Link, RouteComponentProps } from '@reach/router'
+
+import { Icon } from '@/shared/components'
+import { Header, NavigationContainer, StyledIcon, StyledSearchbar, CancelButton, Logo } from './Navbar.style'
+
+const Navbar: React.FC<RouteComponentProps> = () => {
+  const [search, setSearch] = useState('')
+  const [isSearching, setIsSearching] = useState(false)
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setSearch(e.currentTarget.value)
+  }
+
+  const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter' || (e.key === 'NumpadEnter' && search.trim() !== '')) {
+      navigate(`/search/${search}`)
+    }
+  }
+
+  const handleFocus = () => {
+    setIsSearching(true)
+  }
+
+  const handleCancel = () => {
+    setSearch('')
+    setIsSearching(false)
+  }
+  return (
+    <Header isSearching={isSearching}>
+      {!isSearching && (
+        <NavigationContainer>
+          <Link to="/">
+            <Logo />
+          </Link>
+          <Link to="/">
+            <StyledIcon name="home" />
+          </Link>
+          <Link to="/discover">
+            <StyledIcon name="binocular" />
+          </Link>
+        </NavigationContainer>
+      )}
+
+      <StyledSearchbar
+        placeholder="Search..."
+        onChange={handleChange}
+        value={search}
+        onKeyPress={handleKeyPress}
+        onFocus={handleFocus}
+      />
+      {isSearching && (
+        <CancelButton onClick={handleCancel}>
+          <Icon name="times" />
+        </CancelButton>
+      )}
+    </Header>
+  )
+}
+
+export default Navbar

+ 2 - 0
packages/app/src/components/Navbar/index.ts

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

+ 39 - 0
packages/app/src/components/VideoBestMatch/VideoBestMatch.style.tsx

@@ -0,0 +1,39 @@
+import styled from '@emotion/styled'
+import { colors } 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: 650px 1fr;
+  grid-column-gap: 24px;
+`
+export const Poster = styled.img`
+  width: 100%;
+  max-height: 350px;
+  object-fit: cover;
+  object-position: center;
+
+  :hover {
+    cursor: pointer;
+  }
+`
+export const TitleContainer = styled.div`
+  max-width: 500px;
+`
+export const Title = styled.h1`
+  font-size: 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;
+`

+ 32 - 0
packages/app/src/components/VideoBestMatch/VideoBestMatch.tsx

@@ -0,0 +1,32 @@
+import React from 'react'
+
+import { VideoFields } from '@/api/queries/__generated__/VideoFields'
+import { formatNumber } from '@/utils/number'
+import { formatDate } from '@/utils/time'
+import { Container, Content, InnerContainer, TitleContainer, Title, Poster } 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>
+      <Poster src={thumbnailURL} onClick={onClick} />
+      <InnerContainer>
+        <TitleContainer>
+          <Title>{title}</Title>
+          <span>
+            {formatNumber(views)} views • {formatDate(publishedOnJoystreamAt)}
+          </span>
+        </TitleContainer>
+      </InnerContainer>
+    </Content>
+  </Container>
+)
+export default BestVideoMatch

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

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

+ 2 - 0
packages/app/src/components/index.ts

@@ -5,3 +5,5 @@ export { default as TagsGallery } from './TagsGallery'
 export { default as Main } from './Main'
 export { default as SeriesGallery } from './SeriesGallery'
 export { default as ChannelGallery } from './ChannelGallery'
+export { default as Navbar } from './Navbar'
+export { default as VideoBestMatch } from './VideoBestMatch'

+ 2 - 1
packages/app/src/config/routes.ts

@@ -1,3 +1,4 @@
 export default {
-  video: (id = ':id') => `video/${id}`,
+  video: (id = ':id') => `/video/${id}`,
+  search: (searchStr = ':search') => `/search/${searchStr}`,
 }

+ 27 - 0
packages/app/src/mocking/server.ts

@@ -5,6 +5,7 @@ import { shuffle } from 'lodash'
 import schema from '../schema.graphql'
 import { mockChannels, mockVideos } from '@/mocking/data'
 import { GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
+import { SearchVariables } from '@/api/queries/__generated__/Search'
 
 createServer({
   routes() {
@@ -33,6 +34,32 @@ createServer({
             const channels = mirageGraphQLFieldResolver(...params)
             return shuffle(channels)
           },
+          // FIXME: This resolver is currently broken and returns the same result n times instead of the correct result.
+          search: (obj: unknown, { query_string }: SearchVariables, context: unknown, info: unknown) => {
+            const items = [...mockVideos, ...mockChannels]
+
+            let rankCount = 0
+            const matchQueryStr = (str: string) => str.includes(query_string) || query_string.includes(str)
+
+            const relevantItems = items.reduce((acc: any, item) => {
+              const matched =
+                item.__typename === 'Channel'
+                  ? matchQueryStr(item.handle)
+                  : matchQueryStr(item.description) || matchQueryStr(item.title)
+
+              return matched
+                ? [
+                    ...acc,
+                    {
+                      __typename: 'FreeTextSearchResult',
+                      item,
+                      rank: rankCount++,
+                    },
+                  ]
+                : acc
+            }, [])
+            return relevantItems
+          },
         },
       },
     })

+ 18 - 39
packages/app/src/shared/components/NavButton/NavButton.style.ts

@@ -1,42 +1,21 @@
-import { typography, colors } from '../../theme'
-import { StyleFn, makeStyles } from '../../utils'
-
+import styled from '@emotion/styled'
+import { colors } from '../../theme'
+import Button from '../Button'
 export type NavButtonStyleProps = {
-  type?: 'primary' | 'secondary'
+  variant: 'primary' | 'secondary'
 }
 
-const baseStyles: StyleFn = () => ({
-  border: 0,
-  color: colors.white,
-  textAlign: 'center',
-  display: 'inline-block',
-  cursor: 'default',
-  fontFamily: typography.fonts.base,
-  fontWeight: typography.weights.medium,
-  fontSize: typography.sizes.subtitle1,
-  lineHeight: '50px',
-  '&:hover': {
-    borderColor: colors.blue[700],
-  },
-  '&:active': {
-    borderColor: colors.blue[900],
-  },
-  '&::selection': {
-    background: 'transparent',
-  },
-})
-
-const colorFromType: StyleFn = (styles, { type = 'primary' }) => ({
-  ...styles,
-  backgroundColor: type === 'primary' ? colors.blue[700] : colors.black,
-  '&:hover': {
-    backgroundColor: type === 'primary' ? colors.blue[700] : colors.black,
-    color: type === 'primary' ? colors.white : colors.blue[300],
-  },
-  '&:active': {
-    backgroundColor: type === 'primary' ? colors.blue[900] : colors.black,
-    color: type === 'primary' ? colors.white : colors.blue[700],
-  },
-})
-
-export const useCSS = (props: NavButtonStyleProps) => makeStyles([baseStyles, colorFromType])(props)
+export const StyledButton = styled(Button)`
+  color: ${(props) => (props.variant === 'primary' ? colors.white : colors.gray[600])};
+  background-color: ${(props) => (props.variant === 'primary' ? colors.blue[500] : 'transparent')};
+  border: unset;
+  width: 48px;
+  height: 48px;
+  &:hover {
+    color: ${colors.white};
+    background-color: ${(props) => (props.variant === 'primary' ? colors.blue[700] : 'transparent')};
+  }
+  &:active {
+    background-color: ${(props) => (props.variant === 'primary' ? colors.blue[900] : 'transparent')};
+  }
+`

+ 12 - 6
packages/app/src/shared/components/NavButton/NavButton.tsx

@@ -1,19 +1,25 @@
 import React from 'react'
 import { SerializedStyles } from '@emotion/core'
-import { NavButtonStyleProps, useCSS } from './NavButton.style'
+import { NavButtonStyleProps, StyledButton } from './NavButton.style'
 import Icon from '../Icon'
 
 type NavButtonProps = {
   direction: 'right' | 'left'
-  outerCss: SerializedStyles | SerializedStyles[] | (SerializedStyles | undefined) | (SerializedStyles | undefined)[]
+  outerCss: SerializedStyles | (SerializedStyles | undefined)[]
   onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
 } & NavButtonStyleProps
 
-export default function NavButton({ direction = 'right', onClick, outerCss, ...styleProps }: Partial<NavButtonProps>) {
-  const styles = useCSS(styleProps)
+const NavButton: React.FC<Partial<NavButtonProps>> = ({
+  direction = 'right',
+  onClick,
+  outerCss,
+  variant = 'primary',
+}) => {
   return (
-    <button css={[styles, outerCss]} onClick={onClick}>
+    <StyledButton css={outerCss} onClick={onClick} variant={variant}>
       <Icon name={direction === 'right' ? 'chevron-right' : 'chevron-left'} />
-    </button>
+    </StyledButton>
   )
 }
+
+export default NavButton

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

@@ -0,0 +1,17 @@
+import styled from '@emotion/styled'
+import { colors } from '../../theme'
+
+export const Input = styled.input`
+  border: unset;
+  padding: 14px 12px;
+  height: 48px;
+  background-color: ${colors.gray[800]};
+  color: ${colors.white};
+  &::placeholder {
+    color: ${colors.gray[400]};
+  }
+  &:focus {
+    background-color: ${colors.gray[900]};
+    outline: 1px solid ${colors.gray[500]};
+  }
+`

+ 31 - 0
packages/app/src/shared/components/Searchbar/Searchbar.tsx

@@ -0,0 +1,31 @@
+import React from 'react'
+import { Input } from './Searchbar.style'
+
+type SearchbarProps = {
+  value: string
+} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLInputElement>, HTMLInputElement>
+const Searchbar: React.FC<SearchbarProps> = ({
+  placeholder,
+  onChange,
+  onFocus,
+  value,
+  onBlur,
+  onSubmit,
+  ...hmtlProps
+}) => {
+  return (
+    <Input
+      value={value}
+      placeholder={placeholder}
+      type="search"
+      autoSave="some_unique_value"
+      name="s"
+      onChange={onChange}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onSubmit={onSubmit}
+      {...hmtlProps}
+    />
+  )
+}
+export default Searchbar

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

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

+ 23 - 0
packages/app/src/shared/components/TabsMenu/TabMenu.styles.tsx

@@ -0,0 +1,23 @@
+import styled from '@emotion/styled'
+import { colors } from '@/shared/theme'
+
+export const TabsGroup = styled.div`
+  display: flex;
+`
+
+type TabProps = {
+  selected: boolean
+}
+
+export const Tab = styled.div<TabProps>`
+  width: 120px;
+  padding: 22px 0;
+  font-size: 14px;
+  color: ${(props) => (props.selected ? colors.white : colors.gray[300])};
+  text-transform: capitalize;
+  text-align: center;
+  border-bottom: ${(props) => (props.selected ? `4px solid ${colors.blue[500]}` : 'none')};
+  :hover {
+    cursor: pointer;
+  }
+`

+ 29 - 0
packages/app/src/shared/components/TabsMenu/TabsMenu.tsx

@@ -0,0 +1,29 @@
+import React, { useState } from 'react'
+import { TabsGroup, Tab } from './TabMenu.styles'
+
+type TabsMenuProps = {
+  tabs: string[]
+  initialIndex?: number
+  onSelectTab: (idx: number) => void
+}
+const TabsMenu: React.FC<TabsMenuProps> = ({ tabs, onSelectTab, initialIndex = -1 }) => {
+  const [selected, setSelected] = useState(initialIndex)
+
+  return (
+    <TabsGroup>
+      {tabs.map((tab, idx) => (
+        <Tab
+          onClick={(e) => {
+            onSelectTab(idx)
+            setSelected(idx)
+          }}
+          key={`${tab}-${idx}`}
+          selected={selected === idx}
+        >
+          <span>{tab}</span>
+        </Tab>
+      ))}
+    </TabsGroup>
+  )
+}
+export default TabsMenu

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

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

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

@@ -27,3 +27,5 @@ export { default as Placeholder } from './Placeholder'
 export { default as InfiniteVideoGrid } from './InfiniteVideoGrid'
 export { default as ToggleButton } from './ToggleButton'
 export { default as Icon } from './Icon'
+export { default as Searchbar } from './Searchbar'
+export { default as TabsMenu } from './TabsMenu'

+ 2 - 0
packages/app/src/shared/icons/index.ts

@@ -11,6 +11,7 @@ export { ReactComponent as ChevronLeft } from './chevron-left-big.svg'
 export { ReactComponent as Check } from './check.svg'
 export { ReactComponent as Dash } from './dash.svg'
 export { ReactComponent as Play } from './play.svg'
+export { ReactComponent as Times } from './times.svg'
 
 const icons = [
   'bars',
@@ -26,6 +27,7 @@ const icons = [
   'check',
   'dash',
   'play',
+  'times',
 ] as const
 
 export type IconType = typeof icons[number]

+ 5 - 0
packages/app/src/shared/icons/times.svg

@@ -0,0 +1,5 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="M14.2426 15.6569L10 11.4143L5.75736 15.6569L4.34315 14.2427L8.58579 10.0001L4.34315 5.75744L5.75736 4.34323L10 8.58587L14.2426 4.34323L15.6569 5.75744L11.4142 10.0001L15.6569 14.2427L14.2426 15.6569Z"
+        fill="currentColor" />
+</svg>

+ 4 - 4
packages/app/src/shared/stories/02-NavigationButton.stories.tsx

@@ -10,13 +10,13 @@ export const PrimaryRight = () => <NavButton />
 
 export const PrimaryLeft = () => <NavButton direction="left" />
 
-export const SecondaryRight = () => <NavButton type="secondary" />
+export const SecondaryRight = () => <NavButton variant="secondary" />
 
-export const SecondaryLeft = () => <NavButton type="secondary" direction="left" />
+export const SecondaryLeft = () => <NavButton variant="secondary" direction="left" />
 
 export const AppNavigation = () => (
   <div>
-    <NavButton type="secondary" direction="left" />
-    <NavButton type="secondary" direction="right" />
+    <NavButton variant="secondary" direction="left" />
+    <NavButton variant="secondary" direction="right" />
   </div>
 )

+ 3 - 1
packages/app/src/utils/time.ts

@@ -1,4 +1,6 @@
-import { formatDistanceToNowStrict } from 'date-fns'
+import { format, formatDistanceToNowStrict } from 'date-fns'
+
+export const formatDate = (date: Date) => format(date, 'd MMM yyyy')
 
 export const formatDateAgo = (date: Date): string => {
   return `${formatDistanceToNowStrict(date)} ago`

+ 61 - 0
packages/app/src/views/SearchView.tsx

@@ -0,0 +1,61 @@
+import React, { useState, useMemo } from 'react'
+import { css } from '@emotion/core'
+import { RouteComponentProps, navigate } 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 { Main, VideoGallery, ChannelGallery, VideoBestMatch } from '@/components'
+import routes from '@/config/routes'
+
+type SearchViewProps = {
+  search?: string
+} & RouteComponentProps
+const tabs = ['all results', 'videos', 'channels']
+const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
+  const [selectedIndex, setSelectedIndex] = useState(0)
+  const { data, loading } = useQuery<Search, SearchVariables>(SEARCH, { variables: { query_string: search } })
+
+  const getChannelsAndVideos = (loading: boolean, data: Search | undefined) => {
+    if (loading || !data?.search) {
+      return { channels: [], videos: [] }
+    }
+    const results = data.search
+    const videos = results.flatMap((result) => (result.item.__typename === 'Video' ? [result.item] : []))
+    const channels = results.flatMap((result) => (result.item.__typename === 'Channel' ? [result.item] : []))
+    return { channels, videos }
+  }
+
+  const { channels, videos: allVideos } = useMemo(() => getChannelsAndVideos(loading, data), [loading, data])
+
+  if (loading || !data) {
+    return <p>Loading...</p>
+  }
+  if (!data.search) {
+    return <p>Something went wrong...</p>
+  }
+
+  const [bestMatch, ...videos] = allVideos
+  return (
+    <Main
+      containerCss={css`
+        margin: 1rem 0;
+        & > * {
+          margin-bottom: 3rem;
+        }
+      `}
+    >
+      <TabsMenu tabs={tabs} onSelectTab={setSelectedIndex} initialIndex={0} />
+      {bestMatch && <VideoBestMatch video={bestMatch} onClick={() => navigate(routes.video(bestMatch.id))} />}
+      {videos.length > 0 && (selectedIndex === 0 || selectedIndex === 1) && (
+        <VideoGallery title="Videos" action="See all" loading={loading} videos={videos} />
+      )}
+      {channels.length > 0 && (selectedIndex === 0 || selectedIndex === 2) && (
+        <ChannelGallery title="Channels" action="See all" loading={loading} channels={channels} />
+      )}
+    </Main>
+  )
+}
+
+export default SearchView

+ 2 - 2
packages/app/src/views/index.ts

@@ -1,4 +1,4 @@
 import HomeView from './HomeView'
 import VideoView from './VideoView'
-
-export { HomeView, VideoView }
+import SearchView from './SearchView'
+export { HomeView, VideoView, SearchView }