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 }) => (
-
- Photoview -
+
) 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