diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 96171023..1b81f5a8 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -140,6 +140,7 @@ type ComplexityRoot struct {
MyPhotos func(childComplexity int, filter *models.Filter) int
MyUser func(childComplexity int) int
Photo func(childComplexity int, id int) int
+ Search func(childComplexity int, query string, limitPhotos *int, limitAlbums *int) int
ShareToken func(childComplexity int, token string, password *string) int
SiteInfo func(childComplexity int) int
User func(childComplexity int, filter *models.Filter) int
@@ -152,6 +153,12 @@ type ComplexityRoot struct {
Success func(childComplexity int) int
}
+ SearchResult struct {
+ Albums func(childComplexity int) int
+ Photos func(childComplexity int) int
+ Query func(childComplexity int) int
+ }
+
ShareToken struct {
Album func(childComplexity int) int
Expire func(childComplexity int) int
@@ -216,6 +223,7 @@ type QueryResolver interface {
MyPhotos(ctx context.Context, filter *models.Filter) ([]*models.Photo, error)
Photo(ctx context.Context, id int) (*models.Photo, error)
ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
+ Search(ctx context.Context, query string, limitPhotos *int, limitAlbums *int) (*models.SearchResult, error)
}
type ShareTokenResolver interface {
Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error)
@@ -770,6 +778,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Photo(childComplexity, args["id"].(int)), true
+ case "Query.search":
+ if e.complexity.Query.Search == nil {
+ break
+ }
+
+ args, err := ec.field_Query_search_args(context.TODO(), rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Query.Search(childComplexity, args["query"].(string), args["limitPhotos"].(*int), args["limitAlbums"].(*int)), true
+
case "Query.shareToken":
if e.complexity.Query.ShareToken == nil {
break
@@ -829,6 +849,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ScannerResult.Success(childComplexity), true
+ case "SearchResult.albums":
+ if e.complexity.SearchResult.Albums == nil {
+ break
+ }
+
+ return e.complexity.SearchResult.Albums(childComplexity), true
+
+ case "SearchResult.photos":
+ if e.complexity.SearchResult.Photos == nil {
+ break
+ }
+
+ return e.complexity.SearchResult.Photos(childComplexity), true
+
+ case "SearchResult.query":
+ if e.complexity.SearchResult.Query == nil {
+ break
+ }
+
+ return e.complexity.SearchResult.Query(childComplexity), true
+
case "ShareToken.album":
if e.complexity.ShareToken.Album == nil {
break
@@ -1045,6 +1086,8 @@ type Query {
photo(id: Int!): Photo!
shareToken(token: String!, password: String): ShareToken!
+
+ search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
}
type Mutation {
@@ -1234,6 +1277,12 @@ type PhotoEXIF {
"An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int
}
+
+type SearchResult {
+ query: String!
+ albums: [Album!]!
+ photos: [Photo!]!
+}
`},
)
@@ -1623,6 +1672,36 @@ func (ec *executionContext) field_Query_photo_args(ctx context.Context, rawArgs
return args, nil
}
+func (ec *executionContext) field_Query_search_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+ var err error
+ args := map[string]interface{}{}
+ var arg0 string
+ if tmp, ok := rawArgs["query"]; ok {
+ arg0, err = ec.unmarshalNString2string(ctx, tmp)
+ if err != nil {
+ return nil, err
+ }
+ }
+ args["query"] = arg0
+ var arg1 *int
+ if tmp, ok := rawArgs["limitPhotos"]; ok {
+ arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp)
+ if err != nil {
+ return nil, err
+ }
+ }
+ args["limitPhotos"] = arg1
+ var arg2 *int
+ if tmp, ok := rawArgs["limitAlbums"]; ok {
+ arg2, err = ec.unmarshalOInt2ᚖint(ctx, tmp)
+ if err != nil {
+ return nil, err
+ }
+ }
+ args["limitAlbums"] = arg2
+ return args, nil
+}
+
func (ec *executionContext) field_Query_shareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@@ -4325,6 +4404,50 @@ func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql
return ec.marshalNShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
}
+func (ec *executionContext) _Query_search(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ ctx = ec.Tracer.StartFieldExecution(ctx, field)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ ec.Tracer.EndFieldExecution(ctx)
+ }()
+ rctx := &graphql.ResolverContext{
+ Object: "Query",
+ Field: field,
+ Args: nil,
+ IsMethod: true,
+ }
+ ctx = graphql.WithResolverContext(ctx, rctx)
+ rawArgs := field.ArgumentMap(ec.Variables)
+ args, err := ec.field_Query_search_args(ctx, rawArgs)
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ rctx.Args = args
+ ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Query().Search(rctx, args["query"].(string), args["limitPhotos"].(*int), args["limitAlbums"].(*int))
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !ec.HasError(rctx) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*models.SearchResult)
+ rctx.Result = res
+ ctx = ec.Tracer.StartFieldChildExecution(ctx)
+ return ec.marshalNSearchResult2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx, field.Selections, res)
+}
+
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
ctx = ec.Tracer.StartFieldExecution(ctx, field)
defer func() {
@@ -4542,6 +4665,117 @@ func (ec *executionContext) _ScannerResult_message(ctx context.Context, field gr
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
}
+func (ec *executionContext) _SearchResult_query(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+ ctx = ec.Tracer.StartFieldExecution(ctx, field)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ ec.Tracer.EndFieldExecution(ctx)
+ }()
+ rctx := &graphql.ResolverContext{
+ Object: "SearchResult",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+ ctx = graphql.WithResolverContext(ctx, rctx)
+ ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Query, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !ec.HasError(rctx) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(string)
+ rctx.Result = res
+ ctx = ec.Tracer.StartFieldChildExecution(ctx)
+ return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _SearchResult_albums(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+ ctx = ec.Tracer.StartFieldExecution(ctx, field)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ ec.Tracer.EndFieldExecution(ctx)
+ }()
+ rctx := &graphql.ResolverContext{
+ Object: "SearchResult",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+ ctx = graphql.WithResolverContext(ctx, rctx)
+ ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Albums, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !ec.HasError(rctx) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.([]*models.Album)
+ rctx.Result = res
+ ctx = ec.Tracer.StartFieldChildExecution(ctx)
+ return ec.marshalNAlbum2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _SearchResult_photos(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+ ctx = ec.Tracer.StartFieldExecution(ctx, field)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ ec.Tracer.EndFieldExecution(ctx)
+ }()
+ rctx := &graphql.ResolverContext{
+ Object: "SearchResult",
+ Field: field,
+ Args: nil,
+ IsMethod: false,
+ }
+ ctx = graphql.WithResolverContext(ctx, rctx)
+ ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Photos, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !ec.HasError(rctx) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.([]*models.Photo)
+ rctx.Result = res
+ ctx = ec.Tracer.StartFieldChildExecution(ctx)
+ return ec.marshalNPhoto2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhotoᚄ(ctx, field.Selections, res)
+}
+
func (ec *executionContext) _ShareToken_id(ctx context.Context, field graphql.CollectedField, obj *models.ShareToken) (ret graphql.Marshaler) {
ctx = ec.Tracer.StartFieldExecution(ctx, field)
defer func() {
@@ -6836,6 +7070,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
}
return res
})
+ case "search":
+ field := field
+ out.Concurrently(i, func() (res graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ }
+ }()
+ res = ec._Query_search(ctx, field)
+ if res == graphql.Null {
+ atomic.AddUint32(&invalids, 1)
+ }
+ return res
+ })
case "__type":
out.Values[i] = ec._Query___type(ctx, field)
case "__schema":
@@ -6887,6 +7135,43 @@ func (ec *executionContext) _ScannerResult(ctx context.Context, sel ast.Selectio
return out
}
+var searchResultImplementors = []string{"SearchResult"}
+
+func (ec *executionContext) _SearchResult(ctx context.Context, sel ast.SelectionSet, obj *models.SearchResult) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.RequestContext, sel, searchResultImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ var invalids uint32
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("SearchResult")
+ case "query":
+ out.Values[i] = ec._SearchResult_query(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
+ case "albums":
+ out.Values[i] = ec._SearchResult_albums(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
+ case "photos":
+ out.Values[i] = ec._SearchResult_photos(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ invalids++
+ }
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch()
+ if invalids > 0 {
+ return graphql.Null
+ }
+ return out
+}
+
var shareTokenImplementors = []string{"ShareToken"}
func (ec *executionContext) _ShareToken(ctx context.Context, sel ast.SelectionSet, obj *models.ShareToken) graphql.Marshaler {
@@ -7537,6 +7822,20 @@ func (ec *executionContext) marshalNScannerResult2ᚖgithubᚗcomᚋviktorstrate
return ec._ScannerResult(ctx, sel, v)
}
+func (ec *executionContext) marshalNSearchResult2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx context.Context, sel ast.SelectionSet, v models.SearchResult) graphql.Marshaler {
+ return ec._SearchResult(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNSearchResult2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx context.Context, sel ast.SelectionSet, v *models.SearchResult) graphql.Marshaler {
+ if v == nil {
+ if !ec.HasError(graphql.GetResolverContext(ctx)) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ return ec._SearchResult(ctx, sel, v)
+}
+
func (ec *executionContext) marshalNShareToken2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx context.Context, sel ast.SelectionSet, v models.ShareToken) graphql.Marshaler {
return ec._ShareToken(ctx, sel, &v)
}
diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go
index 7f6c3d93..e4070c77 100644
--- a/api/graphql/models/generated.go
+++ b/api/graphql/models/generated.go
@@ -47,6 +47,12 @@ type ScannerResult struct {
Message *string `json:"message"`
}
+type SearchResult struct {
+ Query string `json:"query"`
+ Albums []*Album `json:"albums"`
+ Photos []*Photo `json:"photos"`
+}
+
// General public information about the site
type SiteInfo struct {
InitialSetup bool `json:"initialSetup"`
diff --git a/api/graphql/resolvers/photo.go b/api/graphql/resolvers/photo.go
index fa492181..b6bc05cf 100644
--- a/api/graphql/resolvers/photo.go
+++ b/api/graphql/resolvers/photo.go
@@ -152,30 +152,9 @@ func (r *photoResolver) Thumbnail(ctx context.Context, obj *models.Photo) (*mode
return url, nil
}
-// func processPhoto(db *sql.DB, photo *models.Photo) error {
-// tx, err := db.Begin()
-// if err != nil {
-// return err
-// }
-
-// err = scanner.ProcessPhoto(tx, photo)
-// if err != nil {
-// tx.Rollback()
-// log.Printf("ERROR: Could not process photo: %s\n", err)
-// return errors.New(fmt.Sprintf("Could not process photo: %s\n", err))
-// }
-
-// err = tx.Commit()
-// if err != nil {
-// log.Printf("ERROR: Could not commit photo after process to db: %s\n", err)
-// return err
-// }
-
-// return nil
-// }
-
func (r *photoResolver) Album(ctx context.Context, obj *models.Photo) (*models.Album, error) {
- panic("not implemented")
+ row := r.Database.QueryRow("SELECT album.* from photo JOIN album ON photo.album_id = album.album_id WHERE photo_id = ?", obj.PhotoID)
+ return models.NewAlbumFromRow(row)
}
func (r *photoResolver) Exif(ctx context.Context, obj *models.Photo) (*models.PhotoEXIF, error) {
diff --git a/api/graphql/resolvers/search.go b/api/graphql/resolvers/search.go
new file mode 100644
index 00000000..a8b26ce5
--- /dev/null
+++ b/api/graphql/resolvers/search.go
@@ -0,0 +1,75 @@
+package resolvers
+
+import (
+ "context"
+ "log"
+
+ "github.com/viktorstrate/photoview/api/graphql/auth"
+ "github.com/viktorstrate/photoview/api/graphql/models"
+)
+
+func (r *Resolver) Search(ctx context.Context, query string, _limitPhotos *int, _limitAlbums *int) (*models.SearchResult, error) {
+ user := auth.UserFromContext(ctx)
+ if user == nil {
+ return nil, auth.ErrUnauthorized
+ }
+
+ limitPhotos := 10
+ limitAlbums := 10
+
+ if _limitPhotos != nil {
+ limitPhotos = *_limitPhotos
+ }
+
+ if _limitAlbums != nil {
+ limitAlbums = *_limitAlbums
+ }
+
+ wildQuery := "%" + query + "%"
+
+ photoRows, err := r.Database.Query(`
+ SELECT photo.* FROM photo JOIN album ON photo.album_id = album.album_id
+ WHERE album.owner_id = ? AND photo.title LIKE ? OR photo.path LIKE ?
+ ORDER BY (
+ case when photo.title LIKE ? then 2
+ when photo.path LIKE ? then 1
+ end ) DESC
+ LIMIT ?
+ `, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitPhotos)
+ if err != nil {
+ log.Printf("ERROR: searching photos %s", err)
+ return nil, err
+ }
+
+ photos, err := models.NewPhotosFromRows(photoRows)
+ if err != nil {
+ return nil, err
+ }
+
+ albumRows, err := r.Database.Query(`
+ SELECT * FROM album
+ WHERE owner_id = ? AND title LIKE ? OR path LIKE ?
+ ORDER BY (
+ case when title LIKE ? then 2
+ when path LIKE ? then 1
+ end ) DESC
+ LIMIT ?
+ `, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitAlbums)
+ if err != nil {
+ log.Printf("ERROR: searching albums %s", err)
+ return nil, err
+ }
+
+ albums, err := models.NewAlbumsFromRows(albumRows)
+ if err != nil {
+ return nil, err
+ }
+
+ result := models.SearchResult{
+ Query: query,
+ Photos: photos,
+ Albums: albums,
+ }
+
+ return &result, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index aa26b293..ce2c4415 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -39,6 +39,8 @@ type Query {
photo(id: Int!): Photo!
shareToken(token: String!, password: String): ShareToken!
+
+ search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
}
type Mutation {
@@ -228,3 +230,9 @@ type PhotoEXIF {
"An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int
}
+
+type SearchResult {
+ query: String!
+ albums: [Album!]!
+ photos: [Photo!]!
+}
diff --git a/ui/package.json b/ui/package.json
index d05fd361..6805e5d2 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 0606e13e..ee9d3e67 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 }) => (
@@ -132,9 +118,7 @@ const Layout = ({ children, title }) => (
-
+
)
diff --git a/ui/src/components/header/Header.js b/ui/src/components/header/Header.js
new file mode 100644
index 00000000..e839b9a6
--- /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 = () => (
+
+ Photoview
+ {localStorage.getItem('token') ? : null}
+
+)
+
+export default Header
diff --git a/ui/src/components/header/Searchbar.js b/ui/src/components/header/Searchbar.js
new file mode 100644
index 00000000..5f714df5
--- /dev/null
+++ b/ui/src/components/header/Searchbar.js
@@ -0,0 +1,235 @@
+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) {
+ query
+ albums {
+ id
+ title
+ thumbnail {
+ thumbnail {
+ url
+ }
+ }
+ }
+ photos {
+ id
+ title
+ thumbnail {
+ url
+ }
+ album {
+ id
+ }
+ }
+ }
+ }
+`
+
+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 =
+ }
+
+ return (
+
+
+ {results}
+
+ )
+}
+
+const ResultTitle = styled.h1`
+ font-size: 20px;
+ margin: 12px 0 4px;
+`
+
+const SearchResults = ({ result }) => {
+ const { data, loading } = result
+ const query = data && data.search.query
+
+ 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 => (
+
+ ))
+
+ const photoElements = photos.map(photo => (
+
+ ))
+
+ return (
+ {
+ // Prevent input blur event
+ e.preventDefault()
+ }}
+ show={data}
+ >
+ {message}
+ {albumElements.length > 0 && Albums}
+ {albumElements}
+ {photoElements.length > 0 && Photos}
+ {photoElements}
+
+ )
+}
+
+SearchResults.propTypes = {
+ result: PropTypes.object,
+}
+
+const RowLink = styled(NavLink)`
+ display: flex;
+ align-items: center;
+ color: black;
+`
+
+const PhotoSearchThumbnail = styled(ProtectedImage)`
+ width: 50px;
+ height: 50px;
+ margin: 2px 0;
+ 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 = ({ query, photo }) => (
+
+
+ {searchHighlighted(query, photo.title)}
+
+)
+
+PhotoRow.propTypes = {
+ query: PropTypes.string.isRequired,
+ photo: PropTypes.object.isRequired,
+}
+
+const AlbumRow = ({ query, album }) => (
+
+
+ {searchHighlighted(query, album.title)}
+
+)
+
+AlbumRow.propTypes = {
+ query: PropTypes.string.isRequired,
+ album: PropTypes.object.isRequired,
+}
+
+const searchHighlighted = (query, text) => {
+ const i = text.toLowerCase().indexOf(query.toLowerCase())
+
+ if (i == -1) {
+ return text
+ }
+
+ const start = text.substring(0, i)
+ const middle = text.substring(i, i + query.length)
+ const end = text.substring(i + query.length)
+
+ return (
+ <>
+ {start}
+ {middle}
+ {end}
+ >
+ )
+}
+
+export default SearchBar