Browse Source

Search channel view (#1031)

* search channel view

* Icon and borders

* reset searchbar when going back to videos

* readability ++

* fix search pagination

* handleKey inside hook

* Search limit 100

Co-authored-by: Klaudiusz Dembler <dev@kdembler.com>
Diego Cardenas 3 years ago
parent
commit
11e42b4987

+ 2 - 1
src/api/hooks/channel.ts

@@ -42,7 +42,7 @@ export const useChannel = (id: string, opts?: ChannelOpts) => {
 }
 
 type VideoCountOpts = QueryHookOptions<GetVideoCountQuery>
-export const useChannelVideoCount = (channelId: string, opts?: VideoCountOpts) => {
+export const useChannelVideoCount = (channelId: string, createdAt_gte?: Date, opts?: VideoCountOpts) => {
   const { data, ...rest } = useGetVideoCountQuery({
     ...opts,
     variables: {
@@ -52,6 +52,7 @@ export const useChannelVideoCount = (channelId: string, opts?: VideoCountOpts) =
         mediaAvailability_eq: AssetAvailability.Accepted,
         isPublic_eq: true,
         isCensored_eq: false,
+        createdAt_gte: createdAt_gte,
       },
     },
   })

+ 1 - 1
src/components/ChannelCard.tsx

@@ -15,7 +15,7 @@ type ChannelCardProps = {
 export const ChannelCard: React.FC<ChannelCardProps> = ({ id, className, onClick }) => {
   const { channel, loading } = useChannel(id ?? '', { fetchPolicy: 'cache-first', skip: !id })
   const { url } = useAsset({ entity: channel, assetType: AssetType.AVATAR })
-  const { videoCount } = useChannelVideoCount(id ?? '', {
+  const { videoCount } = useChannelVideoCount(id ?? '', undefined, {
     fetchPolicy: 'cache-first',
     skip: !id,
   })

+ 12 - 3
src/shared/components/Tabs/Tabs.tsx

@@ -14,12 +14,21 @@ export type TabsProps = {
   tabs: TabItem[]
   initialIndex?: number
   onSelectTab: (idx: number) => void
+  selected?: number
+  className?: string
 }
 
 const SCROLL_SHADOW_OFFSET = 10
 
-export const Tabs: React.FC<TabsProps> = ({ tabs, onSelectTab, initialIndex = -1 }) => {
-  const [selected, setSelected] = useState(initialIndex)
+export const Tabs: React.FC<TabsProps> = ({
+  tabs,
+  onSelectTab,
+  initialIndex = -1,
+  selected: paramsSelected,
+  className,
+}) => {
+  const [_selected, setSelected] = useState(initialIndex)
+  const selected = paramsSelected ?? _selected
   const [isContentOverflown, setIsContentOverflown] = useState(false)
   const tabsRef = useRef<HTMLDivElement>(null)
   const [shadowsVisible, setShadowsVisible] = useState({
@@ -71,7 +80,7 @@ export const Tabs: React.FC<TabsProps> = ({ tabs, onSelectTab, initialIndex = -1
   }
 
   return (
-    <TabsWrapper>
+    <TabsWrapper className={className}>
       <CSSTransition
         in={shadowsVisible.left && isContentOverflown}
         timeout={100}

+ 16 - 1
src/shared/components/TextField/TextField.tsx

@@ -11,13 +11,27 @@ export type TextFieldProps = {
   onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
   onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void
   onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void
+  onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void
   required?: boolean
   className?: string
   placeholder?: string
 } & InputBaseProps
 
 const TextFieldComponent: React.ForwardRefRenderFunction<HTMLInputElement, TextFieldProps> = (
-  { name, type = 'text', value, onChange, onBlur, onFocus, error, disabled, required, placeholder, ...inputBaseProps },
+  {
+    name,
+    type = 'text',
+    onKeyDown,
+    value,
+    onChange,
+    onBlur,
+    onFocus,
+    error,
+    disabled,
+    required,
+    placeholder,
+    ...inputBaseProps
+  },
   ref
 ) => {
   return (
@@ -30,6 +44,7 @@ const TextFieldComponent: React.ForwardRefRenderFunction<HTMLInputElement, TextF
         onChange={onChange}
         onFocus={onFocus}
         onBlur={onBlur}
+        onKeyDown={onKeyDown}
         placeholder={placeholder}
         type={type}
         required={required}

+ 14 - 0
src/shared/icons/GlyphSearch.tsx

@@ -0,0 +1,14 @@
+// THIS FILE WAS AUTOGENERATED BY SVGR. DO NOT MODIFY IT MANUALLY.
+import * as React from 'react'
+
+export const SvgGlyphSearch = (props: React.SVGProps<SVGSVGElement>) => (
+  <svg width={16} height={16} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+    <path d="M11 11l3 3" stroke="#F4F6F8" strokeWidth={2} />
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M8 14A6 6 0 108 2a6 6 0 000 12zm0-2a4 4 0 100-8 4 4 0 000 8z"
+      fill="#F4F6F8"
+    />
+  </svg>
+)

+ 1 - 0
src/shared/icons/index.tsx

@@ -40,6 +40,7 @@ export * from './GlyphResize'
 export * from './GlyphRestart'
 export * from './GlyphRetry'
 export * from './GlyphShow'
+export * from './GlyphSearch'
 export * from './GlyphSoundOff'
 export * from './GlyphSoundOn'
 export * from './GlyphTrash'

+ 4 - 0
src/shared/icons/svgs/glyph-search.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 11L14 14" stroke="#F4F6F8" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14ZM8 12C10.2091 12 12 10.2091 12 8C12 5.79086 10.2091 4 8 4C5.79086 4 4 5.79086 4 8C4 10.2091 5.79086 12 8 12Z" fill="#F4F6F8"/>
+</svg>

+ 94 - 9
src/views/viewer/ChannelView/ChannelView.style.tsx

@@ -2,8 +2,8 @@ import styled from '@emotion/styled'
 import { fluidRange } from 'polished'
 
 import { ChannelLink } from '@/components'
-import { Button, SkeletonLoader, Text } from '@/shared/components'
-import { colors, media, sizes, typography } from '@/shared/theme'
+import { Button, IconButton, SkeletonLoader, Tabs, Text, TextField } from '@/shared/components'
+import { colors, media, sizes, transitions, typography } from '@/shared/theme'
 
 const SM_TITLE_HEIGHT = '44px'
 const TITLE_HEIGHT = '51px'
@@ -46,12 +46,18 @@ export const Title = styled(Text)`
 `
 
 export const SortContainer = styled.div`
-  display: none;
+  grid-area: sort;
+  display: grid;
   grid-gap: 8px;
-  grid-template-columns: auto 1fr;
   align-items: center;
+  ${media.base} {
+    grid-template-columns: 1fr;
+  }
+  ${media.small} {
+    grid-template-columns: auto 1fr;
+  }
   ${media.medium} {
-    display: grid;
+    grid-area: initial;
   }
 `
 
@@ -126,15 +132,94 @@ export const PaginationContainer = styled.div`
 `
 
 export const TabsContainer = styled.div`
+  display: grid;
   margin-bottom: ${sizes(8)};
-  border-bottom: solid 1px ${colors.gray[800]};
-
+  gap: ${sizes(2)};
+  grid-template-rows: 1fr 1fr;
+  grid-template-columns: 1fr 1fr;
+  grid-template-areas:
+    'tabs tabs tabs'
+    'search sort sort';
+  align-items: baseline;
   ${media.compact} {
     padding-top: ${sizes(8)};
   }
+  ${media.small} {
+    border-bottom: solid 1px ${colors.gray[800]};
+    grid-template-areas:
+      'tabs tabs '
+      'search  sort';
+  }
+  ${media.medium} {
+    grid-template-areas: initial;
+    gap: ${sizes(8)};
+    grid-template-rows: 1fr;
+    grid-template-columns: auto 1fr 250px;
+  }
+`
+
+export const SearchContainer = styled.div`
+  display: flex;
+  grid-area: search;
+  width: 100%;
+  align-items: center;
+  ${media.base} {
+    align-self: end;
+  }
+  ${media.small} {
+    align-self: initial;
+  }
+  ${media.medium} {
+    grid-area: initial;
+  }
+`
 
+export const StyledTabs = styled(Tabs)`
+  grid-area: tabs;
+  ${media.base} {
+    border-bottom: solid 1px ${colors.gray[800]};
+  }
+  ${media.small} {
+    border-bottom: none;
+  }
   ${media.medium} {
-    display: grid;
-    grid-template-columns: 1fr 250px;
+    grid-area: initial;
   }
 `
+
+type TextFieldProps = {
+  isOpen?: boolean
+}
+export const StyledTextField = styled(TextField)<TextFieldProps>`
+  transition: all ${transitions.timings.regular} ${transitions.easing};
+  will-change: max-width;
+  width: 100%;
+  align-items: center;
+  max-width: ${({ isOpen }) => (isOpen ? '192px' : '0px')};
+
+  > input {
+    ${({ isOpen }) => isOpen === false && 'border: none !important'};
+
+    padding: 10px 16px 10px 42px;
+    caret-color: ${colors.blue[500]};
+
+    &:focus {
+      border: 1px solid ${colors.white};
+    }
+
+    ::-webkit-search-cancel-button {
+      -webkit-appearance: none;
+    }
+  }
+`
+
+export const SearchButton = styled(IconButton)`
+  position: absolute;
+`
+
+export const DialogAccentText = styled.span`
+  font-size: ${typography.sizes.body2};
+  line-height: ${typography.lineHeights.body2};
+  font-weight: ${typography.weights.regular};
+  color: ${colors.gray[50]};
+`

+ 176 - 39
src/views/viewer/ChannelView/ChannelView.tsx

@@ -1,24 +1,36 @@
-import React, { useEffect, useState } from 'react'
+import { subMonths } from 'date-fns'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
 import { useParams } from 'react-router-dom'
 
-import { useChannel, useFollowChannel, useUnfollowChannel, useVideosConnection } from '@/api/hooks'
-import { VideoOrderByInput } from '@/api/queries'
+import {
+  useChannel,
+  useChannelVideoCount,
+  useFollowChannel,
+  useUnfollowChannel,
+  useVideosConnection,
+} from '@/api/hooks'
+import { AssetAvailability, SearchQuery, VideoOrderByInput, useSearchLazyQuery } from '@/api/queries'
 import { LimitedWidthContainer, VideoTile, ViewWrapper } from '@/components'
 import { SORT_OPTIONS } from '@/config/sorting'
 import { AssetType, useAsset, useDialog, usePersonalDataStore } from '@/providers'
-import { ChannelCover, Grid, Pagination, Select, Tabs, Text } from '@/shared/components'
-import { SvgGlyphCheck, SvgGlyphPlus } from '@/shared/icons'
+import { ChannelCover, Grid, Pagination, Select, Text } from '@/shared/components'
+import { SvgGlyphCheck, SvgGlyphPlus, SvgGlyphSearch } from '@/shared/icons'
 import { transitions } from '@/shared/theme'
 import { Logger } from '@/utils/logger'
 import { formatNumberShort } from '@/utils/number'
 
 import { ChannelAbout } from './ChannelAbout'
 import {
+  DialogAccentText,
   PaginationContainer,
+  SearchButton,
+  SearchContainer,
   SortContainer,
   StyledButton,
   StyledButtonContainer,
   StyledChannelLink,
+  StyledTabs,
+  StyledTextField,
   SubTitle,
   SubTitleSkeletonLoader,
   TabsContainer,
@@ -29,6 +41,7 @@ import {
   VideoSection,
 } from './ChannelView.style'
 
+const DATE_ONE_MONTH_PAST = subMonths(new Date(), 1)
 const TABS = ['Videos', 'Information'] as const
 const INITIAL_FIRST = 50
 const INITIAL_VIDEOS_PER_ROW = 4
@@ -37,6 +50,19 @@ export const ChannelView: React.FC = () => {
   const [openUnfollowDialog, closeUnfollowDialog] = useDialog()
   const { id } = useParams()
   const { channel, loading, error } = useChannel(id)
+  const {
+    searchVideos,
+    handleSearchInputKeyPress,
+    loadingSearch,
+    isSearchInputOpen,
+    setIsSearchingInputOpen,
+    searchQuery,
+    setSearchQuery,
+    isSearching,
+    setIsSearching,
+    searchInputRef,
+    errorSearch,
+  } = useSearchVideos({ id })
   const { followChannel } = useFollowChannel()
   const { unfollowChannel } = useUnfollowChannel()
   const followedChannels = usePersonalDataStore((state) => state.followedChannels)
@@ -50,7 +76,7 @@ export const ChannelView: React.FC = () => {
     entity: channel,
     assetType: AssetType.COVER,
   })
-  const { currentPage, setCurrentPage } = usePagination(0)
+  const { currentPage, setCurrentPage, currentSearchPage, setCurrentSearchPage } = usePagination(0)
   const { edges, totalCount, loading: loadingVideos, error: videosError, refetch } = useVideosConnection(
     {
       first: INITIAL_FIRST,
@@ -62,6 +88,7 @@ export const ChannelView: React.FC = () => {
     },
     { notifyOnNetworkStatusChange: true, fetchPolicy: 'cache-and-network' }
   )
+  const { videoCount: videosLastMonth } = useChannelVideoCount(id, DATE_ONE_MONTH_PAST)
   useEffect(() => {
     const isFollowing = followedChannels.some((channel) => channel.id === id)
     setFollowing(isFollowing)
@@ -71,27 +98,27 @@ export const ChannelView: React.FC = () => {
     try {
       if (isFollowing) {
         openUnfollowDialog({
-          variant: 'info',
+          variant: 'error',
           exitButton: false,
-          description: `Do you want to unfollow ${channel?.title}?`,
-          primaryButton: {
-            text: 'Unfollow',
-            textOnly: true,
-            variant: 'destructive-secondary',
-            onClick: () => {
-              updateChannelFollowing(id, false)
-              unfollowChannel(id)
-              setFollowing(false)
-              closeUnfollowDialog()
-            },
+          error: true,
+          title: 'Would you consider staying?',
+          description: (
+            <>
+              {channel?.title} released <DialogAccentText>{videosLastMonth} new videos </DialogAccentText>
+              this month.
+              <br /> Cancel to follow for more fresh content!
+            </>
+          ),
+          primaryButtonText: 'Unfollow',
+          onPrimaryButtonClick: () => {
+            updateChannelFollowing(id, false)
+            unfollowChannel(id)
+            setFollowing(false)
+            closeUnfollowDialog()
           },
-          secondaryButton: {
-            text: 'Cancel',
-            textOnly: true,
-            variant: 'secondary',
-            onClick: () => {
-              closeUnfollowDialog()
-            },
+          secondaryButtonText: 'Keep following',
+          onSecondaryButtonClick: () => {
+            closeUnfollowDialog()
           },
         })
       } else {
@@ -107,9 +134,20 @@ export const ChannelView: React.FC = () => {
     throw videosError
   } else if (error) {
     throw error
+  } else if (errorSearch) {
+    throw errorSearch
   }
 
-  const handleSorting = (value?: VideoOrderByInput | null | undefined) => {
+  const handleSetCurrentTab = async (tab: number) => {
+    if (TABS[tab] === 'Videos' && isSearching) {
+      setIsSearchingInputOpen(false)
+      searchInputRef.current?.blur()
+      setSearchQuery('')
+    }
+    setIsSearching(false)
+    setCurrentVideosTab(tab)
+  }
+  const handleSorting = (value?: VideoOrderByInput | null) => {
     if (value) {
       setSortVideosBy(value)
       refetch({ orderBy: value })
@@ -117,24 +155,25 @@ export const ChannelView: React.FC = () => {
   }
   const handleOnResizeGrid = (sizes: number[]) => setVideosPerRow(sizes.length)
   const handleChangePage = (page: number) => {
-    setCurrentPage(page)
-  }
-  const handleSetCurrentTab = async (tab: number) => {
-    setCurrentVideosTab(tab)
+    isSearching ? setCurrentSearchPage(page) : setCurrentPage(page)
   }
+
   const videosPerPage = ROWS_AMOUNT * videosPerRow
 
-  const videos = edges
-    ?.map((edge) => edge.node)
-    .slice(currentPage * videosPerPage, currentPage * videosPerPage + videosPerPage)
+  const videos = (isSearching ? searchVideos : edges?.map((edge) => edge.node)) ?? []
+  const paginatedVideos = isSearching
+    ? videos.slice(currentSearchPage * videosPerPage, currentSearchPage * videosPerPage + videosPerPage)
+    : videos.slice(currentPage * videosPerPage, currentPage * videosPerPage + videosPerPage)
+
   const placeholderItems = Array.from(
-    { length: loadingVideos ? videosPerPage - (videos ? videos.length : 0) : 0 },
+    { length: loadingVideos || loadingSearch ? videosPerPage - (paginatedVideos ? paginatedVideos.length : 0) : 0 },
     () => ({
       id: undefined,
       progress: undefined,
     })
   )
-  const videosWithPlaceholders = [...(videos || []), ...placeholderItems]
+
+  const videosWithPlaceholders = [...(paginatedVideos || []), ...placeholderItems]
   const mappedTabs = TABS.map((tab) => ({ name: tab, badgeNumber: 0 }))
 
   const TabContent = () => {
@@ -152,9 +191,9 @@ export const ChannelView: React.FC = () => {
             <PaginationContainer>
               <Pagination
                 onChangePage={handleChangePage}
-                page={currentPage}
+                page={isSearching ? currentSearchPage : currentPage}
                 itemsPerPage={videosPerPage}
-                totalCount={totalCount}
+                totalCount={isSearching ? searchVideos?.length : totalCount}
               />
             </PaginationContainer>
           </>
@@ -198,8 +237,36 @@ export const ChannelView: React.FC = () => {
           </StyledButtonContainer>
         </TitleSection>
         <TabsContainer>
-          <Tabs initialIndex={0} tabs={mappedTabs} onSelectTab={handleSetCurrentTab} />
+          <StyledTabs
+            selected={isSearching ? -1 : undefined}
+            initialIndex={0}
+            tabs={mappedTabs}
+            onSelectTab={handleSetCurrentTab}
+          />
           {currentTabName === 'Videos' && (
+            <SearchContainer>
+              <StyledTextField
+                ref={searchInputRef}
+                isOpen={isSearchInputOpen}
+                value={searchQuery}
+                onChange={(e) => setSearchQuery(e.target.value)}
+                onKeyDown={handleSearchInputKeyPress}
+                placeholder="Search"
+                type="search"
+                helperText={null}
+              />
+              <SearchButton
+                onClick={() => {
+                  setIsSearchingInputOpen(true)
+                  searchInputRef.current?.focus()
+                }}
+                variant="tertiary"
+              >
+                <SvgGlyphSearch />
+              </SearchButton>
+            </SearchContainer>
+          )}
+          {currentTabName === 'Videos' && !isSearching && (
             <SortContainer>
               <Text variant="body2">Sort by</Text>
               <Select helperText={null} value={sortVideosBy} items={SORT_OPTIONS} onChange={handleSorting} />
@@ -214,9 +281,79 @@ export const ChannelView: React.FC = () => {
 
 const usePagination = (currentTab: number) => {
   const [currentPage, setCurrentPage] = useState(0)
+  const [currentSearchPage, setCurrentSearchPage] = useState(0)
   // reset the pagination when changing tabs
   useEffect(() => {
     setCurrentPage(0)
+    setCurrentSearchPage(0)
   }, [currentTab])
-  return { currentPage, setCurrentPage }
+  return { currentPage, setCurrentPage, currentSearchPage, setCurrentSearchPage }
+}
+
+const getVideosFromSearch = (loading: boolean, data: SearchQuery['search'] | undefined) => {
+  if (loading || !data) {
+    return { channels: [], videos: [] }
+  }
+  const searchVideos = data.flatMap((result) => (result.item.__typename === 'Video' ? [result.item] : []))
+  return { searchVideos }
+}
+type UseSearchVideosParams = {
+  id: string
+}
+const useSearchVideos = ({ id }: UseSearchVideosParams) => {
+  const [isSearchInputOpen, setIsSearchingInputOpen] = useState(false)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [isSearching, setIsSearching] = useState(false)
+  const [searchVideo, { loading: loadingSearch, data: searchData, error: errorSearch }] = useSearchLazyQuery()
+  const searchInputRef = useRef<HTMLInputElement>(null)
+  const handleSearchInputKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
+    if (event.key === 'Enter' || event.key === 'NumpadEnter') {
+      if (searchQuery.trim() === '') {
+        setSearchQuery('')
+        setIsSearching(false)
+      } else {
+        search()
+        setIsSearching(true)
+      }
+    }
+    if (event.key === 'Escape' || event.key === 'Esc') {
+      setIsSearchingInputOpen(false)
+      searchInputRef.current?.blur()
+      setSearchQuery('')
+    }
+  }
+  const search = () => {
+    searchVideo({
+      variables: {
+        text: searchQuery,
+        whereVideo: {
+          isPublic_eq: true,
+          mediaAvailability_eq: AssetAvailability.Accepted,
+          thumbnailPhotoAvailability_eq: AssetAvailability.Accepted,
+          channelId_eq: id,
+        },
+        limit: 100,
+      },
+    })
+  }
+
+  const { searchVideos } = useMemo(() => getVideosFromSearch(loadingSearch, searchData?.search), [
+    loadingSearch,
+    searchData,
+  ])
+
+  return {
+    searchVideos,
+    search,
+    loadingSearch,
+    isSearchInputOpen,
+    setIsSearchingInputOpen,
+    searchQuery,
+    setSearchQuery,
+    isSearching,
+    setIsSearching,
+    searchInputRef,
+    errorSearch,
+    handleSearchInputKeyPress,
+  }
 }

+ 1 - 0
src/views/viewer/SearchOverlayView/SearchResults/SearchResults.tsx

@@ -26,6 +26,7 @@ export const SearchResults: React.FC<SearchResultsProps> = ({ query }) => {
       thumbnailPhotoAvailability_eq: AssetAvailability.Accepted,
     },
     whereChannel: {},
+    limit: 100,
   })
 
   const getChannelsAndVideos = (loading: boolean, data: SearchQuery['search'] | undefined) => {