From 525edbea07e75e05f394b9bcf003f4fa229e3638 Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Thu, 5 Mar 2020 20:10:43 +0100
Subject: [PATCH] Add search bar

---
 ui/package.json                       |   1 +
 ui/src/Layout.js                      |  22 +--
 ui/src/components/header/Header.js    |  29 ++++
 ui/src/components/header/Searchbar.js | 200 ++++++++++++++++++++++++++
 4 files changed, 233 insertions(+), 19 deletions(-)
 create mode 100644 ui/src/components/header/Header.js
 create mode 100644 ui/src/components/header/Searchbar.js

diff --git a/ui/package.json b/ui/package.json
index d05fd36..6805e5d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -17,6 +17,7 @@
     "downloadjs": "^1.4.7",
     "graphql": "^14.6.0",
     "graphql-tag": "^2.10.3",
+    "lodash": "^4.17.15",
     "parcel-bundler": "^1.12.4",
     "prettier": "^1.19.1",
     "prop-types": "^15.7.2",
diff --git a/ui/src/Layout.js b/ui/src/Layout.js
index 0606e13..ee9d3e6 100644
--- a/ui/src/Layout.js
+++ b/ui/src/Layout.js
@@ -1,4 +1,4 @@
-import React, { Component } from 'react'
+import React from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import { NavLink } from 'react-router-dom'
@@ -8,6 +8,7 @@ import { Query } from 'react-apollo'
 import gql from 'graphql-tag'
 import { Authorized } from './AuthorizedRoute'
 import Helmet from 'react-helmet'
+import Header from './components/header/Header'
 
 const adminQuery = gql`
   query adminQuery {
@@ -76,21 +77,6 @@ const SideButtonLabel = styled.div`
   font-size: 16px;
 `
 
-const Header = styled.div`
-  height: 60px;
-  width: 100%;
-  position: fixed;
-  background: white;
-  top: 0;
-  /* border-bottom: 1px solid rgba(0, 0, 0, 0.1); */
-  box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
-`
-
-const Title = styled.h1`
-  font-size: 36px;
-  padding: 5px 12px;
-`
-
 const Layout = ({ children, title }) => (
   <Container>
     <Helmet>
@@ -132,9 +118,7 @@ const Layout = ({ children, title }) => (
         <div style={{ height: 24 }}></div>
       </Content>
     </Sidebar>
-    <Header>
-      <Title>Photoview</Title>
-    </Header>
+    <Header />
   </Container>
 )
 
diff --git a/ui/src/components/header/Header.js b/ui/src/components/header/Header.js
new file mode 100644
index 0000000..b434e70
--- /dev/null
+++ b/ui/src/components/header/Header.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import styled from 'styled-components'
+import SearchBar from './Searchbar'
+
+const Container = styled.div`
+  height: 60px;
+  width: 100%;
+  display: inline-flex;
+  position: fixed;
+  background: white;
+  top: 0;
+  /* border-bottom: 1px solid rgba(0, 0, 0, 0.1); */
+  box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
+`
+
+const Title = styled.h1`
+  font-size: 36px;
+  padding: 5px 12px;
+  flex-grow: 1;
+`
+
+const Header = () => (
+  <Container>
+    <Title>Photoview</Title>
+    <SearchBar />
+  </Container>
+)
+
+export default Header
diff --git a/ui/src/components/header/Searchbar.js b/ui/src/components/header/Searchbar.js
new file mode 100644
index 0000000..eb10038
--- /dev/null
+++ b/ui/src/components/header/Searchbar.js
@@ -0,0 +1,200 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import styled from 'styled-components'
+import { useLazyQuery } from '@apollo/react-hooks'
+import gql from 'graphql-tag'
+import debounce from 'lodash/debounce'
+import ProtectedImage from '../photoGallery/ProtectedImage'
+import { NavLink } from 'react-router-dom'
+
+const Container = styled.div`
+  height: 60px;
+  width: 350px;
+  margin: 0 12px;
+  padding: 12px 0;
+  position: relative;
+`
+
+const SearchField = styled.input`
+  height: 100%;
+  width: 100%;
+  border: 1px solid #eee;
+  border-radius: 4px;
+  padding: 0 8px;
+  font-size: 16px;
+  font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif;
+
+  &:focus {
+    box-shadow: 0 0 4px #eee;
+    border-color: #3d82c6;
+  }
+`
+
+const Results = styled.div`
+  display: ${({ show }) => (show ? 'block' : 'none')};
+  position: absolute;
+  width: 100%;
+  min-height: 40px;
+  max-height: calc(100vh - 100px);
+  overflow-y: scroll;
+  padding: 28px 4px 32px;
+  background-color: white;
+  box-shadow: 0 0 4px #eee;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  top: 50%;
+  z-index: -1;
+
+  ${SearchField}:not(:focus) ~ & {
+    display: none;
+  }
+`
+
+const SEARCH_QUERY = gql`
+  query searchQuery($query: String!) {
+    search(query: $query) {
+      albums {
+        id
+        title
+        thumbnail {
+          thumbnail {
+            url
+          }
+        }
+      }
+      photos {
+        id
+        title
+        thumbnail {
+          url
+        }
+      }
+    }
+  }
+`
+
+const SearchBar = () => {
+  const [fetchSearches, fetchResult] = useLazyQuery(SEARCH_QUERY)
+  const [query, setQuery] = useState('')
+  const [fetched, setFetched] = useState(false)
+
+  let debouncedFetch = null
+  const fetchEvent = e => {
+    e.persist()
+
+    if (!debouncedFetch) {
+      debouncedFetch = debounce(() => {
+        console.log('searching', e.target.value.trim())
+        fetchSearches({ variables: { query: e.target.value.trim() } })
+        setFetched(true)
+      }, 250)
+    }
+
+    setQuery(e.target.value)
+    if (e.target.value.trim() != '') {
+      debouncedFetch()
+    } else {
+      setFetched(false)
+    }
+  }
+
+  let results = null
+  if (query.trim().length > 0 && fetched) {
+    results = <SearchResults result={fetchResult} />
+  }
+
+  return (
+    <Container>
+      <SearchField type="search" placeholder="Search" onChange={fetchEvent} />
+      {results}
+    </Container>
+  )
+}
+
+const ResultTitle = styled.h1`
+  font-size: 20px;
+  margin: 12px 0 4px;
+`
+
+const SearchResults = ({ result }) => {
+  const { data, loading } = result
+
+  const photos = (data && data.search.photos) || []
+  const albums = (data && data.search.albums) || []
+
+  let message = null
+  if (loading) message = 'Loading results...'
+  else if (data && photos.length == 0 && albums.length == 0)
+    message = 'No results found'
+
+  const albumElements = albums.map(album => (
+    <AlbumRow key={album.id} {...album} />
+  ))
+
+  const photoElements = photos.map(photo => (
+    <PhotoRow key={photo.id} {...photo} />
+  ))
+
+  return (
+    <Results
+      onMouseDown={e => {
+        // Prevent input blur event
+        e.preventDefault()
+      }}
+      show={data}
+    >
+      {message}
+      <ResultTitle>Albums</ResultTitle>
+      {albumElements}
+      <ResultTitle>Photos</ResultTitle>
+      {photoElements}
+    </Results>
+  )
+}
+
+const RowLink = styled(NavLink)`
+  display: flex;
+  align-items: center;
+  border-bottom: 1px solid #eee;
+  color: black;
+`
+
+const PhotoSearchThumbnail = styled(ProtectedImage)`
+  width: 50px;
+  height: 50px;
+  object-fit: contain;
+`
+
+const AlbumSearchThumbnail = styled(ProtectedImage)`
+  width: 50px;
+  height: 50px;
+  margin: 4px 0;
+  border-radius: 4px;
+  border: 1px solid #888;
+  object-fit: cover;
+`
+
+const RowTitle = styled.span`
+  flex-grow: 1;
+  padding-left: 8px;
+`
+
+const PhotoRow = photo => (
+  <RowLink to="/">
+    <PhotoSearchThumbnail src={photo.thumbnail.url} />
+    <RowTitle>{photo.title}</RowTitle>
+  </RowLink>
+)
+
+const AlbumRow = album => (
+  <RowLink to={`/album/${album.id}`}>
+    <AlbumSearchThumbnail src={album.thumbnail.thumbnail.url} />
+    <RowTitle>{album.title}</RowTitle>
+  </RowLink>
+)
+
+SearchResults.propTypes = {
+  result: PropTypes.object,
+}
+
+export default SearchBar
